runspec-webops-core 0.2.0__tar.gz → 0.3.1__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 (19) hide show
  1. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/.gitignore +3 -0
  2. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/CHANGELOG.md +23 -0
  3. runspec_webops_core-0.3.1/PKG-INFO +56 -0
  4. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/README.md +1 -1
  5. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/pyproject.toml +2 -1
  6. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/__init__.py +2 -0
  7. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/http.py +18 -24
  8. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/openapi.py +5 -3
  9. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/tls.py +29 -6
  10. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_http.py +6 -0
  11. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_tls.py +31 -1
  12. runspec_webops_core-0.2.0/PKG-INFO +0 -13
  13. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/dns.py +0 -0
  14. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/errors.py +0 -0
  15. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/runspec_webops_core/proxy.py +0 -0
  16. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/__init__.py +0 -0
  17. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_dns.py +0 -0
  18. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_openapi.py +0 -0
  19. {runspec_webops_core-0.2.0 → runspec_webops_core-0.3.1}/tests/test_proxy.py +0 -0
@@ -59,3 +59,6 @@ chainlit-docs/
59
59
 
60
60
  # Stray committed test venv (removed from tracking)
61
61
  .venv-test/
62
+
63
+ # Private gitleaks rules (see .gitleaks.toml)
64
+ .gitleaks.local.toml
@@ -1,5 +1,28 @@
1
1
  # runspec-webops-core Changelog
2
2
 
3
+ ## [0.3.1] — 2026-11-05
4
+
5
+ ### Fixed
6
+
7
+ - The PyPI page shows the README (`pyproject.toml` didn't declare it).
8
+
9
+ ## [0.3.0] — 2026-09-01
10
+
11
+ ### Added — custom CA bundle for TLS verification
12
+
13
+ - Every network helper gained a `ca_bundle` parameter (a PEM path): `probe`,
14
+ `fetch_headers`, `trace_redirects`, `fetch_body`, `load_document`, and
15
+ `connect_peer`. It verifies TLS against a **private/internal CA or a
16
+ TLS-intercepting proxy** — the common companion to proxy support, since a
17
+ corporate proxy that inspects TLS presents its own CA. `insecure` still turns
18
+ verification off entirely and overrides `ca_bundle`.
19
+ - New `client_ssl_context(insecure=False, ca_bundle=None)` centralises client
20
+ SSL-context construction (used by the HTTP and TLS-trust paths); a bad bundle
21
+ path raises a clear `ConnectError`. Exported from the package.
22
+ - For `connect_peer`, `ca_bundle` affects only the trust verdict — the
23
+ inspection connection stays unverified, so the chain is still reported for an
24
+ untrusted endpoint.
25
+
3
26
  ## [0.2.0] — 2026-08-26
4
27
 
5
28
  ### Added — proxy discovery + CONNECT tunnelling
@@ -0,0 +1,56 @@
1
+ Metadata-Version: 2.5
2
+ Name: runspec-webops-core
3
+ Version: 0.3.1
4
+ Summary: Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable core behind the runspec web runnables (no runspec dependency, no runnables)
5
+ Requires-Python: >=3.10
6
+ Provides-Extra: dev
7
+ Requires-Dist: mypy; extra == 'dev'
8
+ Requires-Dist: pytest>=8.0; extra == 'dev'
9
+ Requires-Dist: pyyaml>=6.0; extra == 'dev'
10
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
11
+ Requires-Dist: types-pyyaml; extra == 'dev'
12
+ Provides-Extra: yaml
13
+ Requires-Dist: pyyaml>=6.0; extra == 'yaml'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # runspec-webops-core
17
+
18
+ Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable logic
19
+ core behind the cross-platform web runnables in
20
+ [`runspec-linux`](https://pypi.org/project/runspec-linux/) and [`runspec-windows`](https://pypi.org/project/runspec-windows/).
21
+
22
+ Stdlib only (`ssl` / `socket` / `http.client`) — **no `runspec` dependency, no
23
+ `runspec.toml`, and no entry points**, so installing it exposes the helper
24
+ functions for import without surfacing any runnables (it is invisible to
25
+ `runspec local` / `runspec serve` discovery).
26
+
27
+ ```python
28
+ from runspec_webops_core import connect_peer, leaf_report, probe, resolve
29
+
30
+ peer = connect_peer("example.com", 443, 10)
31
+ print(leaf_report(peer, warn_days=30)) # days-to-expiry, issuer, SANs, trust
32
+ print(probe("https://example.com", 10)) # status, timing, redirect target
33
+ print(resolve("example.com", want_reverse=True))
34
+ ```
35
+
36
+ | Function | What it does |
37
+ |---|---|
38
+ | `connect_peer` / `leaf_report` / `chain_report` | TLS certificate inspection — expiry, issuer, SANs, fingerprint, full chain, trust result |
39
+ | `probe` / `classify` | Single HTTP(S) request — status, response time, redirect target |
40
+ | `fetch_headers` | Response headers + a security-header report (HSTS, CSP, …) |
41
+ | `trace_redirects` | Walk the redirect chain, hop by hop, with loop detection |
42
+ | `resolve` | Forward (A/AAAA) and reverse (PTR) DNS lookups |
43
+ | `load_document` / `summarize_spec` / `describe_operation` | Read an OpenAPI/Swagger spec (URL or file) and condense its shape — operations, params, request/response schemas — to bounded JSON |
44
+
45
+ OpenAPI/Swagger specs in JSON parse with the stdlib; YAML needs the optional
46
+ `yaml` extra (`pip install runspec-webops-core[yaml]`).
47
+
48
+ Each function returns plain data and *raises* on failure (`ConnectError`,
49
+ `ResolveError` — both subclasses of `WebopsCoreError`).
50
+
51
+ ## Corporate wrapping
52
+
53
+ Depend on `runspec-webops-core` alone, import the helpers, and ship your own
54
+ private package with its own `runspec.toml` that bakes in corporate defaults
55
+ (proxies, allowed hosts, redaction) as plain params — only your wrapped
56
+ runnables surface in the venv, the generic published code stays clean.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable logic
4
4
  core behind the cross-platform web runnables in
5
- [`runspec-linux`](../runspec-linux) and [`runspec-windows`](../runspec-windows).
5
+ [`runspec-linux`](https://pypi.org/project/runspec-linux/) and [`runspec-windows`](https://pypi.org/project/runspec-windows/).
6
6
 
7
7
  Stdlib only (`ssl` / `socket` / `http.client`) — **no `runspec` dependency, no
8
8
  `runspec.toml`, and no entry points**, so installing it exposes the helper
@@ -4,9 +4,10 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspec-webops-core"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  requires-python = ">=3.10"
9
9
  description = "Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable core behind the runspec web runnables (no runspec dependency, no runnables)"
10
+ readme = "README.md"
10
11
  dependencies = []
11
12
 
12
13
  [project.optional-dependencies]
@@ -33,6 +33,7 @@ from runspec_webops_core.tls import (
33
33
  TlsPeer,
34
34
  cert_status,
35
35
  chain_report,
36
+ client_ssl_context,
36
37
  connect_peer,
37
38
  days_until,
38
39
  leaf_report,
@@ -52,6 +53,7 @@ __all__ = [
52
53
  "chain_report",
53
54
  "days_until",
54
55
  "cert_status",
56
+ "client_ssl_context",
55
57
  # http
56
58
  "probe",
57
59
  "classify",
@@ -16,6 +16,7 @@ from urllib.parse import urljoin, urlsplit
16
16
 
17
17
  from runspec_webops_core.errors import ConnectError
18
18
  from runspec_webops_core.proxy import http_connection, resolve_proxy
19
+ from runspec_webops_core.tls import client_ssl_context
19
20
 
20
21
  _USER_AGENT = "runspec-webops"
21
22
 
@@ -43,13 +44,14 @@ def classify(status: int) -> str:
43
44
  return "unknown"
44
45
 
45
46
 
46
- def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str | None = None) -> tuple[int, str, list[tuple[str, str]], float]:
47
+ def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str | None = None, ca_bundle: str | None = None) -> tuple[int, str, list[tuple[str, str]], float]:
47
48
  """Issue one request and return ``(status, reason, headers, elapsed_ms)``.
48
49
 
49
50
  ``proxy`` overrides proxy resolution for this request (``"direct"`` forces no
50
51
  proxy); when ``None`` the proxy is auto-discovered from the environment and,
51
- on Windows, the system configuration. Raises :class:`ConnectError` on any
52
- transport-level failure.
52
+ on Windows, the system configuration. ``ca_bundle`` is a PEM path of extra CA
53
+ certs to verify against (private CA / TLS-intercepting proxy); ``insecure``
54
+ overrides it. Raises :class:`ConnectError` on any transport-level failure.
53
55
  """
54
56
  parts = urlsplit(url)
55
57
  if parts.scheme not in ("http", "https"):
@@ -63,12 +65,7 @@ def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str |
63
65
  if parts.query:
64
66
  path = f"{path}?{parts.query}"
65
67
 
66
- ctx = None
67
- if is_https:
68
- ctx = ssl.create_default_context()
69
- if insecure:
70
- ctx.check_hostname = False
71
- ctx.verify_mode = ssl.CERT_NONE
68
+ ctx = client_ssl_context(insecure, ca_bundle) if is_https else None
72
69
 
73
70
  resolved = resolve_proxy(url, proxy)
74
71
  conn, prefix, extra_headers = http_connection(parts.scheme, parts.hostname, port, timeout, ctx, resolved)
@@ -87,14 +84,16 @@ def _request(url: str, timeout: float, method: str, insecure: bool, proxy: str |
87
84
  conn.close()
88
85
 
89
86
 
90
- def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_bytes: int = 10_000_000, max_redirects: int = 5, proxy: str | None = None) -> tuple[int, str]:
87
+ def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_bytes: int = 10_000_000, max_redirects: int = 5, proxy: str | None = None, ca_bundle: str | None = None) -> tuple[int, str]:
91
88
  """GET a URL and return ``(status, body_text)``, following redirects.
92
89
 
93
90
  Used to retrieve documents (e.g. an OpenAPI spec). The body is size-capped so
94
91
  a runaway endpoint can't exhaust memory. ``proxy`` overrides proxy resolution
95
92
  (``"direct"`` forces none; ``None`` auto-discovers) and is re-evaluated per
96
- redirect hop, so a cross-host bypass is honoured. Raises :class:`ConnectError`
97
- on a transport failure or when the body exceeds ``max_bytes``.
93
+ redirect hop, so a cross-host bypass is honoured. ``ca_bundle`` is a PEM path
94
+ of extra CA certs to verify against (private CA / TLS proxy); ``insecure``
95
+ overrides it. Raises :class:`ConnectError` on a transport failure or when the
96
+ body exceeds ``max_bytes``.
98
97
  """
99
98
  current = url
100
99
  seen: set[str] = set()
@@ -114,12 +113,7 @@ def fetch_body(url: str, timeout: float = 15.0, insecure: bool = False, max_byte
114
113
  if parts.query:
115
114
  path = f"{path}?{parts.query}"
116
115
 
117
- ctx = None
118
- if is_https:
119
- ctx = ssl.create_default_context()
120
- if insecure:
121
- ctx.check_hostname = False
122
- ctx.verify_mode = ssl.CERT_NONE
116
+ ctx = client_ssl_context(insecure, ca_bundle) if is_https else None
123
117
 
124
118
  resolved = resolve_proxy(current, proxy)
125
119
  conn, prefix, extra_headers = http_connection(parts.scheme, parts.hostname, port, timeout, ctx, resolved)
@@ -157,9 +151,9 @@ def _header(headers: list[tuple[str, str]], name: str) -> str | None:
157
151
  return None
158
152
 
159
153
 
160
- def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool = False, proxy: str | None = None) -> dict:
154
+ def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
161
155
  """Single-request probe: status, timing, ``server`` / ``location`` headers."""
162
- status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy)
156
+ status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy, ca_bundle)
163
157
  return {
164
158
  "url": url,
165
159
  "status": status,
@@ -171,9 +165,9 @@ def probe(url: str, timeout: float = 10.0, method: str = "GET", insecure: bool =
171
165
  }
172
166
 
173
167
 
174
- def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecure: bool = False, proxy: str | None = None) -> dict:
168
+ def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
175
169
  """Fetch response headers and report on the security headers (present/missing)."""
176
- status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy)
170
+ status, reason, headers, elapsed = _request(url, timeout, method, insecure, proxy, ca_bundle)
177
171
  header_map = {key.lower(): value for key, value in headers}
178
172
  security: dict[str, str | None] = {}
179
173
  missing: list[str] = []
@@ -194,7 +188,7 @@ def fetch_headers(url: str, timeout: float = 10.0, method: str = "HEAD", insecur
194
188
  }
195
189
 
196
190
 
197
- def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecure: bool = False, proxy: str | None = None) -> dict:
191
+ def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
198
192
  """Walk the redirect chain, recording each hop until a non-3xx or ``max_hops``."""
199
193
  hops: list[dict] = []
200
194
  seen: set[str] = set()
@@ -205,7 +199,7 @@ def trace_redirects(url: str, timeout: float = 10.0, max_hops: int = 10, insecur
205
199
  hops.append({"url": current, "status": None, "status_text": "redirect loop", "location": None, "time_ms": 0.0})
206
200
  break
207
201
  seen.add(current)
208
- status, reason, headers, elapsed = _request(current, timeout, "GET", insecure, proxy)
202
+ status, reason, headers, elapsed = _request(current, timeout, "GET", insecure, proxy, ca_bundle)
209
203
  location = _header(headers, "location")
210
204
  hops.append(
211
205
  {
@@ -39,14 +39,16 @@ class SpecError(WebopsCoreError):
39
39
  # ── loading ───────────────────────────────────────────────────────────────────
40
40
 
41
41
 
42
- def load_document(source: str, timeout: float = 15.0, insecure: bool = False, proxy: str | None = None) -> dict:
42
+ def load_document(source: str, timeout: float = 15.0, insecure: bool = False, proxy: str | None = None, ca_bundle: str | None = None) -> dict:
43
43
  """Fetch (URL) or read (file path) a spec and parse it into a dict.
44
44
 
45
45
  ``proxy`` is forwarded to the fetch for URL sources (``"direct"`` forces no
46
- proxy; ``None`` auto-discovers); it is ignored for local file paths.
46
+ proxy; ``None`` auto-discovers); ``ca_bundle`` (a PEM path) is forwarded too
47
+ for verifying against a private CA / TLS proxy. Both are ignored for local
48
+ file paths.
47
49
  """
48
50
  if source.startswith(("http://", "https://")):
49
- status, body = fetch_body(source, timeout=timeout, insecure=insecure, proxy=proxy)
51
+ status, body = fetch_body(source, timeout=timeout, insecure=insecure, proxy=proxy, ca_bundle=ca_bundle)
50
52
  if status >= 400:
51
53
  raise SpecError(f"fetching {source} returned HTTP {status}")
52
54
  else:
@@ -180,6 +180,24 @@ def chain_report(peer: TlsPeer, now: float | None = None) -> list[dict]:
180
180
  # ── network I/O ───────────────────────────────────────────────────────────────
181
181
 
182
182
 
183
+ def client_ssl_context(insecure: bool = False, ca_bundle: str | None = None) -> ssl.SSLContext:
184
+ """Build a verifying client SSL context, optionally trusting a custom CA bundle.
185
+
186
+ ``ca_bundle`` is a path to a PEM file of extra CA certificate(s) to verify
187
+ against — for a private/internal CA or a TLS-intercepting proxy. ``insecure``
188
+ turns verification off entirely (``CERT_NONE``), which overrides ``ca_bundle``.
189
+ Raises :class:`ConnectError` when the bundle path can't be loaded.
190
+ """
191
+ try:
192
+ ctx = ssl.create_default_context(cafile=ca_bundle) if ca_bundle else ssl.create_default_context()
193
+ except OSError as e:
194
+ raise ConnectError(f"could not load CA bundle {ca_bundle!r}: {e}") from e
195
+ if insecure:
196
+ ctx.check_hostname = False
197
+ ctx.verify_mode = ssl.CERT_NONE
198
+ return ctx
199
+
200
+
183
201
  def _peer_chain(ssock: ssl.SSLSocket) -> list[CertInfo]:
184
202
  """Return the server-presented certificate chain (leaf first).
185
203
 
@@ -225,9 +243,12 @@ def _decode_der(der: bytes) -> dict:
225
243
  Path(path).unlink(missing_ok=True)
226
244
 
227
245
 
228
- def _verify_trust(host: str, port: int, servername: str, timeout: float, proxy: str | None = None) -> tuple[bool, str | None]:
229
- """Second connection with full verification — captures the trust verdict."""
230
- ctx = ssl.create_default_context()
246
+ def _verify_trust(host: str, port: int, servername: str, timeout: float, proxy: str | None = None, ca_bundle: str | None = None) -> tuple[bool, str | None]:
247
+ """Second connection with full verification — captures the trust verdict.
248
+
249
+ ``ca_bundle`` adds a custom CA to the trust store (private CA / TLS proxy).
250
+ """
251
+ ctx = client_ssl_context(ca_bundle=ca_bundle)
231
252
  try:
232
253
  with open_socket(host, port, timeout, proxy) as sock, ctx.wrap_socket(sock, server_hostname=servername):
233
254
  return True, None
@@ -241,13 +262,15 @@ def _verify_trust(host: str, port: int, servername: str, timeout: float, proxy:
241
262
  return False, str(e)
242
263
 
243
264
 
244
- def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername: str | None = None, proxy: str | None = None) -> TlsPeer:
265
+ def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername: str | None = None, proxy: str | None = None, ca_bundle: str | None = None) -> TlsPeer:
245
266
  """Open a TLS connection and capture the cert chain, protocol, cipher, trust.
246
267
 
247
268
  ``proxy`` overrides proxy resolution (``"direct"`` forces none; ``None``
248
269
  auto-discovers from the environment and, on Windows, the system config). When
249
270
  a proxy applies the connection is CONNECT-tunnelled, so the certificate
250
- inspected is the real host's, not the proxy's.
271
+ inspected is the real host's, not the proxy's. ``ca_bundle`` is a PEM path
272
+ used only for the trust verdict — the inspection connection is unverified
273
+ regardless, so the chain is still reported for an untrusted endpoint.
251
274
 
252
275
  Raises :class:`ConnectError` only when the endpoint is unreachable — an
253
276
  untrusted / expired certificate is reported, not raised.
@@ -265,7 +288,7 @@ def connect_peer(host: str, port: int = 443, timeout: float = 10.0, servername:
265
288
  except (OSError, TimeoutError, ssl.SSLError) as e:
266
289
  raise ConnectError(f"connection to {host}:{port} failed: {e}") from e
267
290
 
268
- authorized, auth_error = _verify_trust(host, port, sni, timeout, resolved)
291
+ authorized, auth_error = _verify_trust(host, port, sni, timeout, resolved, ca_bundle)
269
292
  return TlsPeer(
270
293
  chain=chain,
271
294
  protocol=protocol,
@@ -78,6 +78,12 @@ def test_probe_bad_scheme_raises() -> None:
78
78
  probe("ftp://example.com", timeout=2)
79
79
 
80
80
 
81
+ def test_probe_bad_ca_bundle_raises() -> None:
82
+ # A missing CA bundle fails at context build, before any network I/O.
83
+ with pytest.raises(ConnectError, match="CA bundle"):
84
+ probe("https://example.com", timeout=2, ca_bundle="/no/such/ca.pem")
85
+
86
+
81
87
  def test_fetch_headers_flags_missing_security(server: str) -> None:
82
88
  res = fetch_headers(f"{server}/ok", timeout=5)
83
89
  assert res["security"]["strict_transport_security"] == "max-age=63072000"
@@ -1,10 +1,12 @@
1
1
  """Pure TLS reporting logic — no socket required."""
2
2
 
3
+ import ssl
3
4
  from datetime import datetime, timezone
5
+ from pathlib import Path
4
6
 
5
7
  import pytest
6
8
 
7
- from runspec_webops_core import cert_status, chain_report, days_until, leaf_report
9
+ from runspec_webops_core import cert_status, chain_report, client_ssl_context, days_until, leaf_report
8
10
  from runspec_webops_core.errors import ConnectError
9
11
  from runspec_webops_core.tls import (
10
12
  EXPIRED,
@@ -48,6 +50,34 @@ def test_issuer_label_prefers_cn_then_org() -> None:
48
50
  assert _issuer_label(((("organizationName", "Acme"),),)) == "Acme"
49
51
 
50
52
 
53
+ def test_client_ssl_context_default_verifies() -> None:
54
+ ctx = client_ssl_context()
55
+ assert ctx.verify_mode == ssl.CERT_REQUIRED
56
+ assert ctx.check_hostname is True
57
+
58
+
59
+ def test_client_ssl_context_insecure_disables_verification() -> None:
60
+ ctx = client_ssl_context(insecure=True)
61
+ assert ctx.verify_mode == ssl.CERT_NONE
62
+ assert ctx.check_hostname is False
63
+
64
+
65
+ def test_client_ssl_context_loads_ca_bundle(tmp_path) -> None:
66
+ # A valid PEM (a copy of the system default CAs) loads without error.
67
+ system_cafile = ssl.get_default_verify_paths().cafile
68
+ if not system_cafile or not Path(system_cafile).is_file():
69
+ pytest.skip("no system CA bundle to copy")
70
+ bundle = tmp_path / "ca.pem"
71
+ bundle.write_text(Path(system_cafile).read_text())
72
+ ctx = client_ssl_context(ca_bundle=str(bundle))
73
+ assert ctx.verify_mode == ssl.CERT_REQUIRED
74
+
75
+
76
+ def test_client_ssl_context_bad_ca_bundle_raises() -> None:
77
+ with pytest.raises(ConnectError, match="CA bundle"):
78
+ client_ssl_context(ca_bundle="/no/such/ca-bundle.pem")
79
+
80
+
51
81
  def _ssl_info(cn: str, issuer_cn: str, not_after: str) -> dict:
52
82
  return {
53
83
  "subject": ((("commonName", cn),),),
@@ -1,13 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: runspec-webops-core
3
- Version: 0.2.0
4
- Summary: Pure-Python HTTPS/TLS certificate + web-endpoint helpers — the importable core behind the runspec web runnables (no runspec dependency, no runnables)
5
- Requires-Python: >=3.10
6
- Provides-Extra: dev
7
- Requires-Dist: mypy; extra == 'dev'
8
- Requires-Dist: pytest>=8.0; extra == 'dev'
9
- Requires-Dist: pyyaml>=6.0; extra == 'dev'
10
- Requires-Dist: ruff==0.15.20; extra == 'dev'
11
- Requires-Dist: types-pyyaml; extra == 'dev'
12
- Provides-Extra: yaml
13
- Requires-Dist: pyyaml>=6.0; extra == 'yaml'