blacksands-bursar 0.2.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 (26) hide show
  1. blacksands_bursar-0.2.0/PKG-INFO +210 -0
  2. blacksands_bursar-0.2.0/README.md +197 -0
  3. blacksands_bursar-0.2.0/pyproject.toml +14 -0
  4. blacksands_bursar-0.2.0/setup.cfg +4 -0
  5. blacksands_bursar-0.2.0/src/blacksands_bursar/__init__.py +21 -0
  6. blacksands_bursar-0.2.0/src/blacksands_bursar/client.py +212 -0
  7. blacksands_bursar-0.2.0/src/blacksands_bursar/exceptions.py +30 -0
  8. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/__init__.py +29 -0
  9. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/apps.py +35 -0
  10. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/base.py +20 -0
  11. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/certs.py +23 -0
  12. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/compliance.py +21 -0
  13. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/dns.py +25 -0
  14. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/endpoints.py +37 -0
  15. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/manifests.py +22 -0
  16. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/operations.py +45 -0
  17. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/orgs.py +22 -0
  18. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/policies.py +37 -0
  19. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/sessions.py +16 -0
  20. blacksands_bursar-0.2.0/src/blacksands_bursar/resources/verify.py +16 -0
  21. blacksands_bursar-0.2.0/src/blacksands_bursar.egg-info/PKG-INFO +210 -0
  22. blacksands_bursar-0.2.0/src/blacksands_bursar.egg-info/SOURCES.txt +24 -0
  23. blacksands_bursar-0.2.0/src/blacksands_bursar.egg-info/dependency_links.txt +1 -0
  24. blacksands_bursar-0.2.0/src/blacksands_bursar.egg-info/requires.txt +6 -0
  25. blacksands_bursar-0.2.0/src/blacksands_bursar.egg-info/top_level.txt +1 -0
  26. blacksands_bursar-0.2.0/tests/test_resources.py +402 -0
@@ -0,0 +1,210 @@
1
+ Metadata-Version: 2.4
2
+ Name: blacksands-bursar
3
+ Version: 0.2.0
4
+ Summary: Python SDK for the Blacksands Bursar API
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.28.0
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=7.0; extra == "dev"
11
+ Requires-Dist: pytest-mock; extra == "dev"
12
+ Requires-Dist: responses>=0.23; extra == "dev"
13
+
14
+ # Blacksands Bursar Python SDK
15
+
16
+ Python client for the Blacksands Bursar API -- zero-trust network security provisioning, certificate management, compliance, and posture verification.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ pip install blacksands-shield
22
+ ```
23
+
24
+ For development:
25
+
26
+ ```bash
27
+ pip install -e ".[dev]"
28
+ ```
29
+
30
+ ## Quickstart
31
+
32
+ ```python
33
+ from blacksands_shield import ShieldClient
34
+
35
+ client = ShieldClient(
36
+ client_cert="/path/to/client.crt",
37
+ client_key="/path/to/client.key",
38
+ ca_cert="/path/to/blacksands-ca.crt", # optional
39
+ )
40
+
41
+ # Create an organisation
42
+ org = client.orgs.create("Acme Corp", "admin@acme.com", tier="professional")
43
+ org_id = org["data"]["id"]
44
+
45
+ # Register an application
46
+ app = client.apps.register(org_id, "web-frontend", framework="react")
47
+ app_id = app["data"]["id"]
48
+
49
+ # Generate a certificate
50
+ cert = client.certs.generate(app_id)
51
+
52
+ # Provision an endpoint
53
+ ep = client.endpoints.provision(app_id, "ingress", "api.acme.com", 443)
54
+
55
+ # Run a verification scan and wait for results
56
+ scan = client.verify.scan(app_id)
57
+ result = client.operations.poll_until_complete(scan["data"]["operationId"])
58
+ ```
59
+
60
+ ## Authentication
61
+
62
+ The Shield SDK uses mTLS client certificates exclusively. API-key authentication is not supported. An mTLS certificate bundle is issued by a Blacksands administrator through Overwatch or SysAdmin and delivered to the caller out-of-band.
63
+
64
+ ```python
65
+ client = ShieldClient(
66
+ client_cert="/path/to/client.crt",
67
+ client_key="/path/to/client.key",
68
+ )
69
+ ```
70
+
71
+ The cert and key are attached to the underlying `requests` session via `session.cert`.
72
+
73
+ ## Configuration
74
+
75
+ ```python
76
+ client = ShieldClient(
77
+ client_cert="/path/to/client.crt",
78
+ client_key="/path/to/client.key",
79
+ ca_cert="/path/to/blacksands-ca.crt", # optional: pin the server cert
80
+ base_url="https://shield.blacksands.io/v1", # default
81
+ timeout=30, # seconds, default
82
+ max_retries=3, # retries on 5xx / connection errors, default
83
+ )
84
+ ```
85
+
86
+ ## API Reference
87
+
88
+ ### Organisations -- `client.orgs`
89
+
90
+ | Method | Description |
91
+ |--------|-------------|
92
+ | `create(name, contact_email, tier="essentials")` | Create a new organisation |
93
+ | `get(org_id)` | Get organisation by ID |
94
+ | `list(limit=20, offset=0)` | List organisations |
95
+ | `update(org_id, **kwargs)` | Update organisation fields |
96
+
97
+ ### Applications -- `client.apps`
98
+
99
+ | Method | Description |
100
+ |--------|-------------|
101
+ | `register(org_id, name, version=None, framework=None, runtime=None)` | Register a new app |
102
+ | `get(app_id)` | Get application by ID |
103
+ | `list(org_id, limit=20, offset=0)` | List apps for an org |
104
+ | `decommission(app_id)` | Decommission an app |
105
+
106
+ ### Certificates -- `client.certs`
107
+
108
+ | Method | Description |
109
+ |--------|-------------|
110
+ | `generate(app_id, algorithm="RSA-2048")` | Generate a new certificate |
111
+ | `rotate(app_id, cert_id)` | Rotate a certificate |
112
+ | `revoke(app_id, cert_id)` | Revoke a certificate |
113
+ | `list(app_id, limit=20, offset=0)` | List certificates for an app |
114
+
115
+ ### Endpoints -- `client.endpoints`
116
+
117
+ | Method | Description |
118
+ |--------|-------------|
119
+ | `provision(app_id, type, hostname, port, protocol="https")` | Provision an endpoint |
120
+ | `list(app_id, limit=20, offset=0)` | List endpoints |
121
+ | `verify(app_id, endpoint_id)` | Verify an endpoint |
122
+ | `deprovision(app_id, endpoint_id)` | Deprovision an endpoint |
123
+
124
+ ### Policies -- `client.policies`
125
+
126
+ | Method | Description |
127
+ |--------|-------------|
128
+ | `create(app_id, type, name, rules, priority=1)` | Create a policy |
129
+ | `update(app_id, policy_id, **kwargs)` | Update a policy |
130
+ | `apply(app_id)` | Apply all policies for an app |
131
+ | `list(app_id, limit=20, offset=0)` | List policies |
132
+
133
+ ### DNS -- `client.dns`
134
+
135
+ | Method | Description |
136
+ |--------|-------------|
137
+ | `allow(app_id, domain, reason=None)` | Allow a domain |
138
+ | `block(app_id, domain, reason=None)` | Block a domain |
139
+ | `scan(app_id)` | Trigger a DNS scan |
140
+ | `report(app_id)` | Get DNS report |
141
+
142
+ ### Manifests -- `client.manifests`
143
+
144
+ | Method | Description |
145
+ |--------|-------------|
146
+ | `submit(org_id, manifest)` | Submit a deployment manifest |
147
+ | `get(manifest_id)` | Get manifest by ID |
148
+ | `provision(manifest_id)` | Provision from manifest |
149
+ | `diff(manifest_id, compare_version)` | Diff against a version |
150
+
151
+ ### Verify -- `client.verify`
152
+
153
+ | Method | Description |
154
+ |--------|-------------|
155
+ | `scan(app_id)` | Trigger a posture scan |
156
+ | `get_result(operation_id)` | Get scan result |
157
+ | `get_latest(app_id)` | Get latest posture |
158
+
159
+ ### Compliance -- `client.compliance`
160
+
161
+ | Method | Description |
162
+ |--------|-------------|
163
+ | `get_status(app_id)` | Get compliance status |
164
+ | `generate_report(app_id, framework="SOC2")` | Generate a report |
165
+ | `get_controls(framework)` | List controls for a framework |
166
+ | `get_history(app_id)` | Get compliance history |
167
+
168
+ ### Operations -- `client.operations`
169
+
170
+ | Method | Description |
171
+ |--------|-------------|
172
+ | `get_status(operation_id)` | Get operation status |
173
+ | `poll_until_complete(operation_id, timeout=120, interval=3)` | Block until done |
174
+
175
+ ### Sessions -- `client.sessions`
176
+
177
+ | Method | Description |
178
+ |--------|-------------|
179
+ | `list(app_id)` | List active sessions |
180
+ | `get(session_id)` | Get session details |
181
+ | `terminate(session_id)` | Terminate a session |
182
+
183
+ ## Error Handling
184
+
185
+ ```python
186
+ from blacksands_shield.exceptions import (
187
+ ShieldAPIError,
188
+ AuthenticationError,
189
+ RateLimitError,
190
+ NotFoundError,
191
+ ValidationError,
192
+ )
193
+
194
+ try:
195
+ client.orgs.get("nonexistent")
196
+ except NotFoundError as e:
197
+ print(f"Not found: {e} (HTTP {e.status_code})")
198
+ except AuthenticationError:
199
+ print("Check your API key")
200
+ except RateLimitError:
201
+ print("Slow down -- rate limit hit")
202
+ except ShieldAPIError as e:
203
+ print(f"API error: {e}")
204
+ ```
205
+
206
+ All exceptions carry `status_code` and `response` attributes.
207
+
208
+ ## License
209
+
210
+ MIT
@@ -0,0 +1,197 @@
1
+ # Blacksands Bursar Python SDK
2
+
3
+ Python client for the Blacksands Bursar API -- zero-trust network security provisioning, certificate management, compliance, and posture verification.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install blacksands-shield
9
+ ```
10
+
11
+ For development:
12
+
13
+ ```bash
14
+ pip install -e ".[dev]"
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```python
20
+ from blacksands_shield import ShieldClient
21
+
22
+ client = ShieldClient(
23
+ client_cert="/path/to/client.crt",
24
+ client_key="/path/to/client.key",
25
+ ca_cert="/path/to/blacksands-ca.crt", # optional
26
+ )
27
+
28
+ # Create an organisation
29
+ org = client.orgs.create("Acme Corp", "admin@acme.com", tier="professional")
30
+ org_id = org["data"]["id"]
31
+
32
+ # Register an application
33
+ app = client.apps.register(org_id, "web-frontend", framework="react")
34
+ app_id = app["data"]["id"]
35
+
36
+ # Generate a certificate
37
+ cert = client.certs.generate(app_id)
38
+
39
+ # Provision an endpoint
40
+ ep = client.endpoints.provision(app_id, "ingress", "api.acme.com", 443)
41
+
42
+ # Run a verification scan and wait for results
43
+ scan = client.verify.scan(app_id)
44
+ result = client.operations.poll_until_complete(scan["data"]["operationId"])
45
+ ```
46
+
47
+ ## Authentication
48
+
49
+ The Shield SDK uses mTLS client certificates exclusively. API-key authentication is not supported. An mTLS certificate bundle is issued by a Blacksands administrator through Overwatch or SysAdmin and delivered to the caller out-of-band.
50
+
51
+ ```python
52
+ client = ShieldClient(
53
+ client_cert="/path/to/client.crt",
54
+ client_key="/path/to/client.key",
55
+ )
56
+ ```
57
+
58
+ The cert and key are attached to the underlying `requests` session via `session.cert`.
59
+
60
+ ## Configuration
61
+
62
+ ```python
63
+ client = ShieldClient(
64
+ client_cert="/path/to/client.crt",
65
+ client_key="/path/to/client.key",
66
+ ca_cert="/path/to/blacksands-ca.crt", # optional: pin the server cert
67
+ base_url="https://shield.blacksands.io/v1", # default
68
+ timeout=30, # seconds, default
69
+ max_retries=3, # retries on 5xx / connection errors, default
70
+ )
71
+ ```
72
+
73
+ ## API Reference
74
+
75
+ ### Organisations -- `client.orgs`
76
+
77
+ | Method | Description |
78
+ |--------|-------------|
79
+ | `create(name, contact_email, tier="essentials")` | Create a new organisation |
80
+ | `get(org_id)` | Get organisation by ID |
81
+ | `list(limit=20, offset=0)` | List organisations |
82
+ | `update(org_id, **kwargs)` | Update organisation fields |
83
+
84
+ ### Applications -- `client.apps`
85
+
86
+ | Method | Description |
87
+ |--------|-------------|
88
+ | `register(org_id, name, version=None, framework=None, runtime=None)` | Register a new app |
89
+ | `get(app_id)` | Get application by ID |
90
+ | `list(org_id, limit=20, offset=0)` | List apps for an org |
91
+ | `decommission(app_id)` | Decommission an app |
92
+
93
+ ### Certificates -- `client.certs`
94
+
95
+ | Method | Description |
96
+ |--------|-------------|
97
+ | `generate(app_id, algorithm="RSA-2048")` | Generate a new certificate |
98
+ | `rotate(app_id, cert_id)` | Rotate a certificate |
99
+ | `revoke(app_id, cert_id)` | Revoke a certificate |
100
+ | `list(app_id, limit=20, offset=0)` | List certificates for an app |
101
+
102
+ ### Endpoints -- `client.endpoints`
103
+
104
+ | Method | Description |
105
+ |--------|-------------|
106
+ | `provision(app_id, type, hostname, port, protocol="https")` | Provision an endpoint |
107
+ | `list(app_id, limit=20, offset=0)` | List endpoints |
108
+ | `verify(app_id, endpoint_id)` | Verify an endpoint |
109
+ | `deprovision(app_id, endpoint_id)` | Deprovision an endpoint |
110
+
111
+ ### Policies -- `client.policies`
112
+
113
+ | Method | Description |
114
+ |--------|-------------|
115
+ | `create(app_id, type, name, rules, priority=1)` | Create a policy |
116
+ | `update(app_id, policy_id, **kwargs)` | Update a policy |
117
+ | `apply(app_id)` | Apply all policies for an app |
118
+ | `list(app_id, limit=20, offset=0)` | List policies |
119
+
120
+ ### DNS -- `client.dns`
121
+
122
+ | Method | Description |
123
+ |--------|-------------|
124
+ | `allow(app_id, domain, reason=None)` | Allow a domain |
125
+ | `block(app_id, domain, reason=None)` | Block a domain |
126
+ | `scan(app_id)` | Trigger a DNS scan |
127
+ | `report(app_id)` | Get DNS report |
128
+
129
+ ### Manifests -- `client.manifests`
130
+
131
+ | Method | Description |
132
+ |--------|-------------|
133
+ | `submit(org_id, manifest)` | Submit a deployment manifest |
134
+ | `get(manifest_id)` | Get manifest by ID |
135
+ | `provision(manifest_id)` | Provision from manifest |
136
+ | `diff(manifest_id, compare_version)` | Diff against a version |
137
+
138
+ ### Verify -- `client.verify`
139
+
140
+ | Method | Description |
141
+ |--------|-------------|
142
+ | `scan(app_id)` | Trigger a posture scan |
143
+ | `get_result(operation_id)` | Get scan result |
144
+ | `get_latest(app_id)` | Get latest posture |
145
+
146
+ ### Compliance -- `client.compliance`
147
+
148
+ | Method | Description |
149
+ |--------|-------------|
150
+ | `get_status(app_id)` | Get compliance status |
151
+ | `generate_report(app_id, framework="SOC2")` | Generate a report |
152
+ | `get_controls(framework)` | List controls for a framework |
153
+ | `get_history(app_id)` | Get compliance history |
154
+
155
+ ### Operations -- `client.operations`
156
+
157
+ | Method | Description |
158
+ |--------|-------------|
159
+ | `get_status(operation_id)` | Get operation status |
160
+ | `poll_until_complete(operation_id, timeout=120, interval=3)` | Block until done |
161
+
162
+ ### Sessions -- `client.sessions`
163
+
164
+ | Method | Description |
165
+ |--------|-------------|
166
+ | `list(app_id)` | List active sessions |
167
+ | `get(session_id)` | Get session details |
168
+ | `terminate(session_id)` | Terminate a session |
169
+
170
+ ## Error Handling
171
+
172
+ ```python
173
+ from blacksands_shield.exceptions import (
174
+ ShieldAPIError,
175
+ AuthenticationError,
176
+ RateLimitError,
177
+ NotFoundError,
178
+ ValidationError,
179
+ )
180
+
181
+ try:
182
+ client.orgs.get("nonexistent")
183
+ except NotFoundError as e:
184
+ print(f"Not found: {e} (HTTP {e.status_code})")
185
+ except AuthenticationError:
186
+ print("Check your API key")
187
+ except RateLimitError:
188
+ print("Slow down -- rate limit hit")
189
+ except ShieldAPIError as e:
190
+ print(f"API error: {e}")
191
+ ```
192
+
193
+ All exceptions carry `status_code` and `response` attributes.
194
+
195
+ ## License
196
+
197
+ MIT
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blacksands-bursar"
7
+ version = "0.2.0"
8
+ description = "Python SDK for the Blacksands Bursar API"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ dependencies = ["requests>=2.28.0"]
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=7.0", "pytest-mock", "responses>=0.23"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,21 @@
1
+ """Blacksands Bursar Python SDK."""
2
+
3
+ from blacksands_bursar.client import BursarClient
4
+ from blacksands_bursar.exceptions import (
5
+ BursarAPIError,
6
+ AuthenticationError,
7
+ RateLimitError,
8
+ NotFoundError,
9
+ ValidationError,
10
+ )
11
+
12
+ __version__ = "0.2.0"
13
+ __all__ = [
14
+ "BursarClient",
15
+ "BursarAPIError",
16
+ "AuthenticationError",
17
+ "RateLimitError",
18
+ "NotFoundError",
19
+ "ValidationError",
20
+ "__version__",
21
+ ]
@@ -0,0 +1,212 @@
1
+ """Main Bursar API client, ported from shieldApiClient.js."""
2
+
3
+ import time
4
+
5
+ import requests
6
+
7
+ from blacksands_bursar.exceptions import (
8
+ AuthenticationError,
9
+ NotFoundError,
10
+ RateLimitError,
11
+ BursarAPIError,
12
+ ValidationError,
13
+ )
14
+ from blacksands_bursar.resources.apps import AppResource
15
+ from blacksands_bursar.resources.certs import CertResource
16
+ from blacksands_bursar.resources.compliance import ComplianceResource
17
+ from blacksands_bursar.resources.dns import DnsResource
18
+ from blacksands_bursar.resources.endpoints import EndpointResource
19
+ from blacksands_bursar.resources.manifests import ManifestResource
20
+ from blacksands_bursar.resources.operations import OperationResource
21
+ from blacksands_bursar.resources.orgs import OrgResource
22
+ from blacksands_bursar.resources.policies import PolicyResource
23
+ from blacksands_bursar.resources.sessions import SessionResource
24
+ from blacksands_bursar.resources.verify import VerifyResource
25
+
26
+ _ERROR_MAP = {
27
+ 400: ValidationError,
28
+ 401: AuthenticationError,
29
+ 403: AuthenticationError,
30
+ 404: NotFoundError,
31
+ 422: ValidationError,
32
+ 429: RateLimitError,
33
+ }
34
+
35
+ DEFAULT_BASE_URL = "https://shield.blacksands.io/v1"
36
+ DEFAULT_TIMEOUT = 30
37
+ DEFAULT_MAX_RETRIES = 3
38
+
39
+
40
+ class BursarClient:
41
+ """Python client for the Blacksands Bursar API.
42
+
43
+ Authentication is mTLS only. API keys are not supported.
44
+
45
+ Args:
46
+ client_cert: Path to the PEM client certificate file.
47
+ client_key: Path to the PEM client private key file.
48
+ ca_cert: Optional path to a PEM CA bundle to pin the server cert.
49
+ base_url: Override the default API base URL.
50
+ timeout: Request timeout in seconds.
51
+ max_retries: Number of retries for transient (5xx) failures.
52
+ """
53
+
54
+ def __init__(
55
+ self,
56
+ client_cert: str,
57
+ client_key: str,
58
+ ca_cert: str | None = None,
59
+ base_url: str = DEFAULT_BASE_URL,
60
+ timeout: int = DEFAULT_TIMEOUT,
61
+ max_retries: int = DEFAULT_MAX_RETRIES,
62
+ ):
63
+ if not client_cert or not client_key:
64
+ raise ValueError(
65
+ "BursarClient requires client_cert and client_key. "
66
+ "mTLS is the only supported authentication method."
67
+ )
68
+
69
+ self.base_url = base_url.rstrip("/")
70
+ self.timeout = timeout
71
+ self.max_retries = max_retries
72
+
73
+ self.session = requests.Session()
74
+ self.session.headers.update({"Content-Type": "application/json"})
75
+ self.session.cert = (client_cert, client_key)
76
+ if ca_cert:
77
+ self.session.verify = ca_cert
78
+
79
+ # Lazy-initialised resource singletons
80
+ self._orgs = None
81
+ self._apps = None
82
+ self._certs = None
83
+ self._endpoints = None
84
+ self._policies = None
85
+ self._dns = None
86
+ self._manifests = None
87
+ self._verify = None
88
+ self._compliance = None
89
+ self._operations = None
90
+ self._sessions = None
91
+
92
+ # -- resource properties --------------------------------------------------
93
+
94
+ @property
95
+ def orgs(self) -> OrgResource:
96
+ if self._orgs is None:
97
+ self._orgs = OrgResource(self)
98
+ return self._orgs
99
+
100
+ @property
101
+ def apps(self) -> AppResource:
102
+ if self._apps is None:
103
+ self._apps = AppResource(self)
104
+ return self._apps
105
+
106
+ @property
107
+ def certs(self) -> CertResource:
108
+ if self._certs is None:
109
+ self._certs = CertResource(self)
110
+ return self._certs
111
+
112
+ @property
113
+ def endpoints(self) -> EndpointResource:
114
+ if self._endpoints is None:
115
+ self._endpoints = EndpointResource(self)
116
+ return self._endpoints
117
+
118
+ @property
119
+ def policies(self) -> PolicyResource:
120
+ if self._policies is None:
121
+ self._policies = PolicyResource(self)
122
+ return self._policies
123
+
124
+ @property
125
+ def dns(self) -> DnsResource:
126
+ if self._dns is None:
127
+ self._dns = DnsResource(self)
128
+ return self._dns
129
+
130
+ @property
131
+ def manifests(self) -> ManifestResource:
132
+ if self._manifests is None:
133
+ self._manifests = ManifestResource(self)
134
+ return self._manifests
135
+
136
+ @property
137
+ def verify(self) -> VerifyResource:
138
+ if self._verify is None:
139
+ self._verify = VerifyResource(self)
140
+ return self._verify
141
+
142
+ @property
143
+ def compliance(self) -> ComplianceResource:
144
+ if self._compliance is None:
145
+ self._compliance = ComplianceResource(self)
146
+ return self._compliance
147
+
148
+ @property
149
+ def operations(self) -> OperationResource:
150
+ if self._operations is None:
151
+ self._operations = OperationResource(self)
152
+ return self._operations
153
+
154
+ @property
155
+ def sessions(self) -> SessionResource:
156
+ if self._sessions is None:
157
+ self._sessions = SessionResource(self)
158
+ return self._sessions
159
+
160
+ # -- core request plumbing ------------------------------------------------
161
+
162
+ def _request(self, method: str, path: str, **kwargs):
163
+ """Send an HTTP request with retry + exponential back-off.
164
+
165
+ Mirrors the JS ``_request(method, path, data, retries)`` pattern:
166
+ - Retries up to ``max_retries`` on 5xx / connection errors.
167
+ - Maps 4xx status codes to typed exceptions immediately (no retry).
168
+ """
169
+ url = f"{self.base_url}{path}"
170
+ kwargs.setdefault("timeout", self.timeout)
171
+
172
+ last_exc = None
173
+ for attempt in range(1, self.max_retries + 1):
174
+ try:
175
+ resp = self.session.request(method, url, **kwargs)
176
+
177
+ if resp.status_code >= 400:
178
+ error_body = {}
179
+ try:
180
+ error_body = resp.json()
181
+ except ValueError:
182
+ pass
183
+ message = error_body.get("error", resp.text or "Unknown error")
184
+
185
+ exc_cls = _ERROR_MAP.get(resp.status_code, BursarAPIError)
186
+
187
+ # Do not retry client errors (4xx) except 429
188
+ if resp.status_code < 500 and resp.status_code != 429:
189
+ raise exc_cls(message, status_code=resp.status_code, response=resp)
190
+
191
+ # 429 and 5xx are retryable
192
+ last_exc = exc_cls(message, status_code=resp.status_code, response=resp)
193
+ if attempt == self.max_retries:
194
+ raise last_exc
195
+
196
+ time.sleep(2**attempt)
197
+ continue
198
+
199
+ # Success
200
+ try:
201
+ return resp.json()
202
+ except ValueError:
203
+ return resp.text
204
+
205
+ except (requests.ConnectionError, requests.Timeout) as exc:
206
+ last_exc = BursarAPIError(str(exc))
207
+ if attempt == self.max_retries:
208
+ raise last_exc from exc
209
+ time.sleep(2**attempt)
210
+
211
+ # Should not reach here, but just in case
212
+ raise last_exc # pragma: no cover
@@ -0,0 +1,30 @@
1
+ """Exception classes for the Blacksands Bursar SDK."""
2
+
3
+
4
+ class BursarAPIError(Exception):
5
+ """Base exception for Bursar API errors."""
6
+
7
+ def __init__(self, message, status_code=None, response=None):
8
+ super().__init__(message)
9
+ self.status_code = status_code
10
+ self.response = response
11
+
12
+
13
+ class AuthenticationError(BursarAPIError):
14
+ """Raised when API authentication fails (401)."""
15
+ pass
16
+
17
+
18
+ class RateLimitError(BursarAPIError):
19
+ """Raised when the API rate limit is exceeded (429)."""
20
+ pass
21
+
22
+
23
+ class NotFoundError(BursarAPIError):
24
+ """Raised when a requested resource is not found (404)."""
25
+ pass
26
+
27
+
28
+ class ValidationError(BursarAPIError):
29
+ """Raised when request validation fails (400/422)."""
30
+ pass