pyswig-dev 0.2.0__py3-none-any.whl

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.
pyswig_dev/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """PySwig development tooling (environment setup, SWIG provisioning)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0"
pyswig_dev/cli.py ADDED
@@ -0,0 +1,31 @@
1
+ """Console entry point for pyswig-dev."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+
8
+ def main(argv: list[str] | None = None) -> int:
9
+ args = list(sys.argv[1:] if argv is None else argv)
10
+ if not args or args[0] in ("-h", "--help"):
11
+ print("usage: pyswig-dev <command> [options]")
12
+ print("commands: setup, test-all")
13
+ return 0 if args and args[0] in ("-h", "--help") else 1
14
+
15
+ command, rest = args[0], args[1:]
16
+ if command == "setup":
17
+ from pyswig_dev.setup_env import main as setup_main
18
+
19
+ return setup_main(rest)
20
+
21
+ if command == "test-all":
22
+ from pyswig_dev.test_all import main as test_main
23
+
24
+ return test_main(rest)
25
+
26
+ print(f"unknown command: {command}", file=sys.stderr)
27
+ return 2
28
+
29
+
30
+ if __name__ == "__main__":
31
+ raise SystemExit(main())
pyswig_dev/config.py ADDED
@@ -0,0 +1,111 @@
1
+ """Load shared tool settings from the repository pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+
11
+ @lru_cache(maxsize=1)
12
+ def repo_root() -> Path:
13
+ """Return the PySwig git checkout root (directory with [tool.pyswig])."""
14
+ start = Path(__file__).resolve()
15
+ for candidate in (start, *start.parents):
16
+ pyproject = candidate / "pyproject.toml"
17
+ if not pyproject.is_file():
18
+ continue
19
+ data = _load_toml(pyproject)
20
+ if data.get("tool", {}).get("pyswig"):
21
+ return candidate
22
+ raise SystemExit("could not find repo root (pyproject.toml with [tool.pyswig])")
23
+
24
+
25
+ ROOT = repo_root # lazy alias for callers that expect ROOT as callable path source
26
+
27
+
28
+ def pyproject_path() -> Path:
29
+ return repo_root() / "pyproject.toml"
30
+
31
+
32
+ def _load_toml(path: Path) -> dict[str, Any]:
33
+ if sys.version_info >= (3, 11):
34
+ import tomllib
35
+
36
+ with path.open("rb") as handle:
37
+ return cast(dict[str, Any], tomllib.load(handle))
38
+ try:
39
+ import tomli
40
+ except ImportError as exc:
41
+ raise SystemExit(
42
+ "Reading pyproject.toml requires Python 3.11+ or the 'tomli' package."
43
+ ) from exc
44
+ with path.open("rb") as handle:
45
+ return cast(dict[str, Any], tomli.load(handle))
46
+
47
+
48
+ def load_pyswig_tool_config() -> dict[str, Any]:
49
+ """Return the [tool.pyswig] table from pyproject.toml."""
50
+ data = _load_toml(pyproject_path())
51
+ return cast(dict[str, Any], data.get("tool", {}).get("pyswig", {}))
52
+
53
+
54
+ def test_python_versions() -> list[str]:
55
+ """Python versions that must pass in CI and local test_all."""
56
+ values = load_pyswig_tool_config().get("test-python-versions", [])
57
+ if not values:
58
+ raise SystemExit("tool.pyswig.test-python-versions is empty in pyproject.toml")
59
+ return [str(item) for item in values]
60
+
61
+
62
+ def coverage_python_version() -> str:
63
+ """Python version used for coverage measurement."""
64
+ value = load_pyswig_tool_config().get("coverage-python-version")
65
+ if not value:
66
+ raise SystemExit("tool.pyswig.coverage-python-version is missing in pyproject.toml")
67
+ return str(value)
68
+
69
+
70
+ def default_dev_python_version() -> str:
71
+ """Default interpreter for the contributor .venv."""
72
+ value = load_pyswig_tool_config().get("default-dev-python-version")
73
+ if value:
74
+ return str(value)
75
+ return coverage_python_version()
76
+
77
+
78
+ def integration_swig_versions() -> list[str]:
79
+ """Pinned SWIG versions exercised by simple wrapper integration tests."""
80
+ values = load_pyswig_tool_config().get("integration-swig-versions", [])
81
+ return [str(item) for item in values]
82
+
83
+
84
+ def integration_python_versions() -> list[str]:
85
+ """Python versions exercised by simple wrapper integration tests."""
86
+ values = load_pyswig_tool_config().get("integration-python-versions", [])
87
+ return [str(item) for item in values]
88
+
89
+
90
+ def integration_matrix_cells() -> list[tuple[str, str]]:
91
+ """All Python × SWIG combinations from pyproject.toml."""
92
+ return [
93
+ (python_version, swig_version)
94
+ for python_version in integration_python_versions()
95
+ for swig_version in integration_swig_versions()
96
+ ]
97
+
98
+
99
+ def integration_swig_cache_dir() -> Path:
100
+ """Directory where integration tests cache downloaded or built SWIG binaries."""
101
+ value = load_pyswig_tool_config().get("integration-swig-cache-dir", ".cache/swig")
102
+ path = Path(str(value))
103
+ if path.is_absolute():
104
+ return path
105
+ return repo_root() / path
106
+
107
+
108
+ def badge_filename(python_version: str) -> str:
109
+ """Return the public badge path for a Python version (e.g. public/python311-badge.svg)."""
110
+ compact = python_version.replace(".", "")
111
+ return f"public/python{compact}-badge.svg"
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env python3
2
+ """Bootstrap a cross-platform pyswig development environment using uv."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import platform
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+
13
+ from pyswig_dev.config import default_dev_python_version, repo_root, test_python_versions
14
+ from pyswig_dev.ssl_util import (
15
+ configure_ssl_environment,
16
+ configure_ssl_fallback_bundle,
17
+ ensure_python_ssl_packages,
18
+ log_ssl_configuration,
19
+ looks_like_tls_failure,
20
+ )
21
+ from pyswig_dev.uv_util import (
22
+ augment_path_for_uv,
23
+ describe_uv_command,
24
+ find_uv_command,
25
+ uv_install_hint,
26
+ )
27
+
28
+ REPO_ROOT = repo_root()
29
+
30
+ UV_INSTALL_URL = "https://astral.sh/uv/install.sh"
31
+ UV_INSTALL_PS1 = "https://astral.sh/uv/install.ps1"
32
+
33
+
34
+ def log_ok(message: str) -> None:
35
+ print(f"[ok] {message}")
36
+
37
+
38
+ def log_skip(message: str) -> None:
39
+ print(f"[skip] {message}")
40
+
41
+
42
+ def log_install(message: str) -> None:
43
+ print(f"[install] {message}")
44
+
45
+
46
+ def log_warn(message: str) -> None:
47
+ print(f"[warn] {message}")
48
+
49
+
50
+ def _base_env() -> dict[str, str]:
51
+ env = os.environ.copy()
52
+ configure_ssl_environment(env)
53
+ env.setdefault("UV_PROJECT_ENVIRONMENT", str(REPO_ROOT / ".venv"))
54
+ return env
55
+
56
+
57
+ def run(
58
+ command: list[str],
59
+ *,
60
+ check: bool = True,
61
+ capture: bool = False,
62
+ env: dict[str, str] | None = None,
63
+ ) -> subprocess.CompletedProcess[str]:
64
+ display = " ".join(command)
65
+ print(f"+ {display}")
66
+ merged_env = _base_env()
67
+ if env:
68
+ merged_env.update(env)
69
+ return subprocess.run(
70
+ command,
71
+ cwd=REPO_ROOT,
72
+ check=check,
73
+ text=True,
74
+ capture_output=capture,
75
+ env=merged_env,
76
+ )
77
+
78
+
79
+ def _install_uv_via_pip() -> None:
80
+ log_install("uv not found; installing with pip (--user)")
81
+ run(
82
+ [sys.executable, "-m", "pip", "install", "--user", "uv"],
83
+ check=False,
84
+ )
85
+ augment_path_for_uv()
86
+
87
+
88
+ def _install_uv_via_official_installer() -> None:
89
+ system = platform.system()
90
+ log_install("uv still not found; running official Astral installer")
91
+ if system == "Windows":
92
+ run(
93
+ [
94
+ "powershell",
95
+ "-NoProfile",
96
+ "-ExecutionPolicy",
97
+ "Bypass",
98
+ "-Command",
99
+ f"irm {UV_INSTALL_PS1} | iex",
100
+ ],
101
+ check=False,
102
+ )
103
+ else:
104
+ run(["sh", "-c", f"curl -LsSf {UV_INSTALL_URL} | sh"], check=False)
105
+ augment_path_for_uv()
106
+
107
+
108
+ def bootstrap_uv() -> list[str]:
109
+ """Install uv when missing; safe to call only after find_uv_command() returned None."""
110
+ _install_uv_via_pip()
111
+ found = find_uv_command()
112
+ if found:
113
+ return found
114
+
115
+ _install_uv_via_official_installer()
116
+ found = find_uv_command()
117
+ if found:
118
+ return found
119
+
120
+ raise SystemExit(uv_install_hint())
121
+
122
+
123
+ def ensure_uv(*, allow_install: bool) -> list[str]:
124
+ """Locate uv, installing it when allowed and not already present."""
125
+ found = find_uv_command()
126
+ if found:
127
+ result = run([*found, "--version"], capture=True)
128
+ log_skip(f"uv already available at {describe_uv_command(found)} ({result.stdout.strip()})")
129
+ return found
130
+
131
+ log_install("uv not found on PATH or as python -m uv")
132
+ if not allow_install:
133
+ raise SystemExit(
134
+ f"{uv_install_hint()}\nRe-run without --skip-uv-install to install uv automatically."
135
+ )
136
+
137
+ command = bootstrap_uv()
138
+ result = run([*command, "--version"], capture=True)
139
+ log_ok(
140
+ f"uv installed and available at {describe_uv_command(command)} ({result.stdout.strip()})"
141
+ )
142
+ return command
143
+
144
+
145
+ def python_is_available(uv: list[str], python_version: str) -> str | None:
146
+ result = subprocess.run(
147
+ [*uv, "python", "find", python_version],
148
+ cwd=REPO_ROOT,
149
+ text=True,
150
+ capture_output=True,
151
+ check=False,
152
+ env=_base_env(),
153
+ )
154
+ if result.returncode != 0:
155
+ return None
156
+ path = result.stdout.strip().splitlines()
157
+ return path[-1] if path else result.stdout.strip() or None
158
+
159
+
160
+ def _log_install_failure(python_version: str, output: str, *, used_fallback: bool = False) -> None:
161
+ log_warn(f"Python {python_version} could not be installed; continuing setup")
162
+ if not output.strip():
163
+ return
164
+ for line in output.strip().splitlines()[-8:]:
165
+ log_warn(f" {line}")
166
+ if looks_like_tls_failure(output):
167
+ if used_fallback:
168
+ log_warn(
169
+ " TLS hint: retried with a merged Windows+certifi CA bundle and still failed. "
170
+ "Ask IT for a corporate CA PEM and set SSL_CERT_FILE before re-running."
171
+ )
172
+ else:
173
+ log_warn(
174
+ " TLS hint: uv uses the OS trust store (UV_SYSTEM_CERTS). "
175
+ "A merged CA bundle retry will run automatically on certificate errors."
176
+ )
177
+
178
+
179
+ def _install_python_with_uv(
180
+ uv: list[str], python_version: str
181
+ ) -> tuple[subprocess.CompletedProcess[str], bool]:
182
+ env = _base_env()
183
+ completed = subprocess.run(
184
+ [*uv, "python", "install", python_version],
185
+ cwd=REPO_ROOT,
186
+ text=True,
187
+ capture_output=True,
188
+ check=False,
189
+ env=env,
190
+ )
191
+ if completed.returncode == 0:
192
+ return completed, False
193
+
194
+ output = completed.stderr or completed.stdout
195
+ if not looks_like_tls_failure(output):
196
+ return completed, False
197
+
198
+ fallback_env = _base_env()
199
+ bundle = configure_ssl_fallback_bundle(fallback_env)
200
+ if bundle is None:
201
+ return completed, False
202
+
203
+ log_install(f"retrying Python {python_version} install with merged CA bundle ({bundle.name})")
204
+ log_ssl_configuration(fallback_env)
205
+ retried = subprocess.run(
206
+ [*uv, "python", "install", python_version],
207
+ cwd=REPO_ROOT,
208
+ text=True,
209
+ capture_output=True,
210
+ check=False,
211
+ env=fallback_env,
212
+ )
213
+ return retried, True
214
+
215
+
216
+ def ensure_python(uv: list[str], python_version: str) -> str | None:
217
+ """Ensure a Python version is available; return path or None on install failure."""
218
+ found = python_is_available(uv, python_version)
219
+ if found:
220
+ log_skip(f"Python {python_version} already available ({found})")
221
+ return found
222
+
223
+ log_install(f"Python {python_version} not found; installing with uv")
224
+ completed, used_fallback = _install_python_with_uv(uv, python_version)
225
+ if completed.returncode != 0:
226
+ output = completed.stderr or completed.stdout
227
+ _log_install_failure(python_version, output, used_fallback=used_fallback)
228
+ return None
229
+
230
+ found = python_is_available(uv, python_version)
231
+ if not found:
232
+ _log_install_failure(python_version, "uv python install succeeded but python find failed")
233
+ return None
234
+
235
+ log_ok(f"Python {python_version} installed ({found})")
236
+ return found
237
+
238
+
239
+ def ensure_project_venv(uv: list[str], python_version: str, *, recreate: bool) -> bool:
240
+ """Create .venv and install dev deps; return False when the dev Python is unavailable."""
241
+ if python_is_available(uv, python_version) is None:
242
+ log_warn(
243
+ f"skipping .venv setup because Python {python_version} is not available "
244
+ "(install failed or was skipped)"
245
+ )
246
+ return False
247
+
248
+ venv_dir = REPO_ROOT / ".venv"
249
+ if recreate and venv_dir.exists():
250
+ log_install(f"removing existing {venv_dir.name}")
251
+ shutil.rmtree(venv_dir)
252
+
253
+ if venv_dir.exists():
254
+ log_skip(f"virtual environment already exists at {venv_dir}")
255
+ else:
256
+ log_install(f"creating virtual environment at {venv_dir} (Python {python_version})")
257
+ completed = subprocess.run(
258
+ [*uv, "venv", str(venv_dir), "--python", python_version],
259
+ cwd=REPO_ROOT,
260
+ text=True,
261
+ capture_output=True,
262
+ check=False,
263
+ env=_base_env(),
264
+ )
265
+ if completed.returncode != 0:
266
+ _log_install_failure(python_version, completed.stderr or completed.stdout)
267
+ return False
268
+
269
+ log_install("installing editable packages (pyswig, pyswig-dev, contributor tools)")
270
+ completed = subprocess.run(
271
+ [
272
+ *uv,
273
+ "pip",
274
+ "install",
275
+ "-e",
276
+ "packages/pyswig[contributor]",
277
+ "-e",
278
+ "packages/pyswig-dev",
279
+ ],
280
+ cwd=REPO_ROOT,
281
+ text=True,
282
+ capture_output=True,
283
+ check=False,
284
+ env=_base_env(),
285
+ )
286
+ if completed.returncode != 0:
287
+ log_warn("editable dev install failed:")
288
+ for line in (completed.stderr or completed.stdout).strip().splitlines()[-8:]:
289
+ log_warn(f" {line}")
290
+ return False
291
+
292
+ log_ok("editable install complete (pyswig + pyswig-dev)")
293
+ return True
294
+
295
+
296
+ def ensure_pre_commit_hooks(*, skip: bool) -> bool:
297
+ """Install pre-commit and commit-msg (gitlint) hooks into .git/hooks."""
298
+ if skip:
299
+ log_skip("pre-commit hook install skipped")
300
+ return True
301
+
302
+ if sys.platform == "win32":
303
+ pre_commit = REPO_ROOT / ".venv" / "Scripts" / "pre-commit.exe"
304
+ else:
305
+ pre_commit = REPO_ROOT / ".venv" / "bin" / "pre-commit"
306
+
307
+ if not pre_commit.is_file():
308
+ log_warn("pre-commit not found in .venv; run setup without --skip-project-venv first")
309
+ return False
310
+
311
+ log_install("installing pre-commit hooks (pre-commit + commit-msg/gitlint)")
312
+ run([str(pre_commit), "install"])
313
+ run([str(pre_commit), "install", "--hook-type", "commit-msg"])
314
+ log_ok("pre-commit hooks installed")
315
+ return True
316
+
317
+
318
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
319
+ parser = argparse.ArgumentParser(
320
+ description=__doc__,
321
+ epilog=(
322
+ "When uv is missing, this script tries to install it automatically "
323
+ "(pip --user, then the official Astral installer). "
324
+ "TLS trust is configured via UV_SYSTEM_CERTS and certifi."
325
+ ),
326
+ )
327
+ parser.add_argument(
328
+ "--skip-uv-install",
329
+ action="store_true",
330
+ help="Fail instead of installing uv when it is not already available",
331
+ )
332
+ parser.add_argument(
333
+ "--skip-project-venv",
334
+ action="store_true",
335
+ help="Only ensure test Python versions; do not create .venv",
336
+ )
337
+ parser.add_argument(
338
+ "--recreate-venv",
339
+ action="store_true",
340
+ help="Delete .venv before creating it again",
341
+ )
342
+ parser.add_argument(
343
+ "--skip-pre-commit",
344
+ action="store_true",
345
+ help="Do not install pre-commit and commit-msg hooks",
346
+ )
347
+ parser.add_argument(
348
+ "--python-version",
349
+ help="Override default dev venv Python version",
350
+ )
351
+ return parser.parse_args(argv)
352
+
353
+
354
+ def main(argv: list[str] | None = None) -> int:
355
+ args = parse_args(argv)
356
+ versions = test_python_versions()
357
+ dev_version = args.python_version or default_dev_python_version()
358
+
359
+ print(f"==> pyswig dev setup ({platform.system().lower()})")
360
+ print(f" test Python versions: {', '.join(versions)}")
361
+ print(f" default dev venv: Python {dev_version}")
362
+
363
+ ensure_python_ssl_packages()
364
+ log_ssl_configuration(_base_env())
365
+
366
+ uv = ensure_uv(allow_install=not args.skip_uv_install)
367
+
368
+ missing_versions: list[str] = []
369
+ for version in versions:
370
+ if ensure_python(uv, version) is None:
371
+ missing_versions.append(version)
372
+
373
+ venv_ready = True
374
+ if not args.skip_project_venv:
375
+ if dev_version not in versions:
376
+ if ensure_python(uv, dev_version) is None and dev_version not in missing_versions:
377
+ missing_versions.append(dev_version)
378
+ venv_ready = ensure_project_venv(uv, dev_version, recreate=args.recreate_venv)
379
+
380
+ hooks_ready = True
381
+ if venv_ready and not args.skip_pre_commit:
382
+ hooks_ready = ensure_pre_commit_hooks(skip=False)
383
+ elif args.skip_pre_commit:
384
+ ensure_pre_commit_hooks(skip=True)
385
+
386
+ print("")
387
+ if missing_versions:
388
+ log_warn(f"Python version(s) not available: {', '.join(missing_versions)}")
389
+ log_warn(
390
+ "Remove them from pyproject.toml [tool.pyswig] test-python-versions if unsupported"
391
+ )
392
+ if not venv_ready and not args.skip_project_venv:
393
+ log_warn("default .venv was not created; fix the dev Python version or TLS settings above")
394
+ if not hooks_ready and not args.skip_pre_commit and venv_ready:
395
+ log_warn("pre-commit hooks were not installed")
396
+
397
+ if (
398
+ missing_versions
399
+ or (not venv_ready and not args.skip_project_venv)
400
+ or (not hooks_ready and not args.skip_pre_commit and venv_ready)
401
+ ):
402
+ print("")
403
+ log_warn("setup completed with warnings")
404
+ return 1
405
+
406
+ log_ok("development environment ready")
407
+ print(" run all version tests: pyswig-dev test-all")
408
+ print(" run default venv tests: .venv\\Scripts\\python -m pytest (Windows)")
409
+ print(" .venv/bin/python -m pytest (Unix)")
410
+ print(" manual hook check: .venv\\Scripts\\pre-commit run --all-files")
411
+ return 0
412
+
413
+
414
+ if __name__ == "__main__":
415
+ raise SystemExit(main())
pyswig_dev/ssl_util.py ADDED
@@ -0,0 +1,122 @@
1
+ """Configure TLS trust for uv, pip, and other HTTPS clients used by setup scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+
11
+ def ensure_python_ssl_packages() -> None:
12
+ """Install certifi and optional trust helpers for the bootstrap interpreter."""
13
+ packages = ["certifi"]
14
+ if sys.version_info >= (3, 10):
15
+ packages.append("truststore")
16
+ if sys.platform == "win32":
17
+ packages.append("pip-system-certs")
18
+
19
+ print("[install] ensuring Python SSL certificate packages (certifi, truststore)")
20
+ subprocess.run(
21
+ [sys.executable, "-m", "pip", "install", "--user", *packages],
22
+ check=False,
23
+ text=True,
24
+ )
25
+
26
+
27
+ def _certifi_bundle_path() -> str | None:
28
+ try:
29
+ import certifi
30
+ except ImportError:
31
+ return None
32
+ return str(certifi.where())
33
+
34
+
35
+ def configure_ssl_environment(env: dict[str, str]) -> dict[str, str]:
36
+ """Return env with TLS settings for uv (OS trust store) and pip (truststore)."""
37
+ # uv: trust the OS certificate store. Do not set SSL_CERT_FILE here — it overrides
38
+ # the default source entirely and can hide corporate roots that live in the OS store.
39
+ env.setdefault("UV_SYSTEM_CERTS", "true")
40
+
41
+ if sys.version_info >= (3, 10):
42
+ env.setdefault("PIP_USE_TRUSTSTORE", "true")
43
+
44
+ return env
45
+
46
+
47
+ def configure_ssl_fallback_bundle(env: dict[str, str]) -> Path | None:
48
+ """Build a merged PEM bundle (certifi + Windows roots) for a retry after TLS failures."""
49
+ if sys.platform != "win32":
50
+ return None
51
+
52
+ certifi_path = _certifi_bundle_path()
53
+ windows_pem = _export_windows_root_certs_pem()
54
+ if certifi_path is None and windows_pem is None:
55
+ return None
56
+
57
+ merged = Path(tempfile.gettempdir()) / "pyswig-merged-ca-bundle.pem"
58
+ parts: list[str] = []
59
+ if certifi_path:
60
+ parts.append(Path(certifi_path).read_text(encoding="ascii"))
61
+ if windows_pem:
62
+ parts.append(windows_pem.read_text(encoding="ascii"))
63
+ merged.write_text("\n".join(parts), encoding="ascii")
64
+ env["SSL_CERT_FILE"] = str(merged)
65
+ env["REQUESTS_CA_BUNDLE"] = str(merged)
66
+ env["CURL_CA_BUNDLE"] = str(merged)
67
+ return merged
68
+
69
+
70
+ def _export_windows_root_certs_pem() -> Path | None:
71
+ """Export LocalMachine and CurrentUser root CAs to a temporary PEM file."""
72
+ destination = Path(tempfile.gettempdir()) / "pyswig-windows-root-certs.pem"
73
+ ps_script = """
74
+ $dest = $args[0]
75
+ $lines = New-Object System.Collections.Generic.List[string]
76
+ foreach ($storePath in @('Cert:\\LocalMachine\\Root', 'Cert:\\CurrentUser\\Root')) {
77
+ Get-ChildItem $storePath -ErrorAction SilentlyContinue | ForEach-Object {
78
+ $bytes = $_.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert)
79
+ $b64 = [Convert]::ToBase64String($bytes, [System.Base64FormattingOptions]::InsertLineBreaks)
80
+ $lines.Add('-----BEGIN CERTIFICATE-----')
81
+ $lines.Add($b64)
82
+ $lines.Add('-----END CERTIFICATE-----')
83
+ }
84
+ }
85
+ if ($lines.Count -eq 0) { exit 1 }
86
+ Set-Content -Path $dest -Value ($lines -join [Environment]::NewLine) -Encoding ascii
87
+ """
88
+ completed = subprocess.run(
89
+ [
90
+ "powershell",
91
+ "-NoProfile",
92
+ "-ExecutionPolicy",
93
+ "Bypass",
94
+ "-Command",
95
+ ps_script,
96
+ str(destination),
97
+ ],
98
+ check=False,
99
+ text=True,
100
+ capture_output=True,
101
+ )
102
+ if completed.returncode != 0 or not destination.is_file():
103
+ return None
104
+ return destination
105
+
106
+
107
+ def log_ssl_configuration(env: dict[str, str]) -> None:
108
+ """Print which TLS-related settings are active."""
109
+ parts = []
110
+ if env.get("UV_SYSTEM_CERTS") == "true":
111
+ parts.append("UV_SYSTEM_CERTS=true (uv uses OS trust store)")
112
+ if env.get("SSL_CERT_FILE"):
113
+ parts.append(f"SSL_CERT_FILE={env['SSL_CERT_FILE']} (fallback bundle)")
114
+ if parts:
115
+ print("[ok] TLS configuration:")
116
+ for part in parts:
117
+ print(f" {part}")
118
+
119
+
120
+ def looks_like_tls_failure(output: str) -> bool:
121
+ lowered = output.lower()
122
+ return "unknownissuer" in lowered or "certificate" in lowered or "tls" in lowered
@@ -0,0 +1,194 @@
1
+ """Download or build pinned SWIG versions for integration tests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import tarfile
11
+ import urllib.error
12
+ import urllib.request
13
+ import zipfile
14
+ from pathlib import Path
15
+
16
+ _DOWNLOAD_BASE = "https://downloads.sourceforge.net/project/swig"
17
+ _VERSION_LINE = re.compile(r"SWIG Version\s+(\S+)")
18
+
19
+
20
+ class SwigProvisionError(RuntimeError):
21
+ """Raised when a requested SWIG version cannot be provisioned."""
22
+
23
+
24
+ def _download(url: str, destination: Path) -> None:
25
+ destination.parent.mkdir(parents=True, exist_ok=True)
26
+ request = urllib.request.Request(url, headers={"User-Agent": "pyswig-integration-tests"})
27
+ try:
28
+ with urllib.request.urlopen(request, timeout=120) as response:
29
+ destination.write_bytes(response.read())
30
+ except urllib.error.URLError as exc:
31
+ raise SwigProvisionError(f"failed to download {url}: {exc}") from exc
32
+
33
+
34
+ def read_swig_version(swig_exe: Path) -> str:
35
+ result = subprocess.run(
36
+ [str(swig_exe), "-version"],
37
+ capture_output=True,
38
+ text=True,
39
+ check=False,
40
+ )
41
+ if result.returncode != 0:
42
+ raise SwigProvisionError(f"{swig_exe} -version failed:\n{result.stderr}")
43
+ for line in result.stdout.splitlines():
44
+ match = _VERSION_LINE.search(line)
45
+ if match:
46
+ return match.group(1)
47
+ raise SwigProvisionError(f"could not parse SWIG version from:\n{result.stdout}")
48
+
49
+
50
+ def find_swig_on_path(version: str) -> Path | None:
51
+ for directory in os_path_entries():
52
+ for name in ("swig.exe", "swig"):
53
+ candidate = directory / name
54
+ if not candidate.is_file():
55
+ continue
56
+ try:
57
+ if read_swig_version(candidate) == version:
58
+ return candidate
59
+ except SwigProvisionError:
60
+ continue
61
+ return None
62
+
63
+
64
+ def os_path_entries() -> list[Path]:
65
+ path_env = os.environ.get("PATH", "")
66
+ return [Path(entry) for entry in path_env.split(os.pathsep) if entry]
67
+
68
+
69
+ def swig_install_root(swig_exe: Path) -> Path:
70
+ """Directory passed to the SWIG / ESYS_SWIG environment variables."""
71
+ return swig_exe.parent
72
+
73
+
74
+ def swig_env(swig_exe: Path) -> dict[str, str]:
75
+ root = swig_install_root(swig_exe)
76
+ return {"SWIG": str(root), "ESYS_SWIG": str(root)}
77
+
78
+
79
+ def ensure_swig(version: str, cache_dir: Path) -> Path:
80
+ """Return a SWIG executable for ``version``, downloading or building it if needed."""
81
+ cached = _cached_swig_exe(cache_dir, version)
82
+ if cached is not None:
83
+ return cached
84
+
85
+ on_path = find_swig_on_path(version)
86
+ if on_path is not None:
87
+ return on_path
88
+
89
+ cache_dir.mkdir(parents=True, exist_ok=True)
90
+ if sys.platform == "win32":
91
+ return _provision_swigwin(version, cache_dir)
92
+ return _provision_swig_unix(version, cache_dir)
93
+
94
+
95
+ def _cached_swig_exe(cache_dir: Path, version: str) -> Path | None:
96
+ version_dir = cache_dir / version
97
+ if sys.platform == "win32":
98
+ candidates = sorted(version_dir.glob("swigwin-*/swig.exe"))
99
+ else:
100
+ candidates = [version_dir / "bin" / "swig"]
101
+ for candidate in candidates:
102
+ if not candidate.is_file():
103
+ continue
104
+ try:
105
+ if read_swig_version(candidate) == version:
106
+ return candidate
107
+ except SwigProvisionError:
108
+ continue
109
+ return None
110
+
111
+
112
+ def _provision_swigwin(version: str, cache_dir: Path) -> Path:
113
+ version_dir = cache_dir / version
114
+ archive = version_dir / f"swigwin-{version}.zip"
115
+ url = f"{_DOWNLOAD_BASE}/swigwin/swigwin-{version}/swigwin-{version}.zip"
116
+ if not archive.is_file():
117
+ _download(url, archive)
118
+
119
+ extract_dir = version_dir / "extract"
120
+ if extract_dir.is_dir():
121
+ shutil.rmtree(extract_dir)
122
+ extract_dir.mkdir(parents=True, exist_ok=True)
123
+ with zipfile.ZipFile(archive) as zf:
124
+ zf.extractall(extract_dir)
125
+
126
+ matches = sorted(extract_dir.glob("swigwin-*/swig.exe"))
127
+ if not matches:
128
+ matches = sorted(extract_dir.rglob("swig.exe"))
129
+ if not matches:
130
+ raise SwigProvisionError(f"swig.exe not found after extracting {archive}")
131
+
132
+ swig_exe = matches[0]
133
+ if read_swig_version(swig_exe) != version:
134
+ raise SwigProvisionError(f"expected SWIG {version}, got {read_swig_version(swig_exe)}")
135
+ return swig_exe
136
+
137
+
138
+ def _provision_swig_unix(version: str, cache_dir: Path) -> Path:
139
+ version_dir = cache_dir / version
140
+ install_prefix = version_dir
141
+ archive = version_dir / f"swig-{version}.tar.gz"
142
+ url = f"{_DOWNLOAD_BASE}/swig/swig-{version}/swig-{version}.tar.gz"
143
+ if not archive.is_file():
144
+ _download(url, archive)
145
+
146
+ source_root = version_dir / f"swig-{version}"
147
+ if not source_root.is_dir():
148
+ with tarfile.open(archive, "r:gz") as tar:
149
+ tar.extractall(version_dir)
150
+ if not source_root.is_dir():
151
+ raise SwigProvisionError(f"source tree not found in {archive}")
152
+
153
+ build_dir = version_dir / "build"
154
+ if build_dir.is_dir():
155
+ shutil.rmtree(build_dir)
156
+ build_dir.mkdir(parents=True, exist_ok=True)
157
+
158
+ configure = source_root / "configure"
159
+ if not configure.is_file():
160
+ raise SwigProvisionError(f"{configure} not found; cannot build SWIG {version}")
161
+
162
+ prefix_arg = str(install_prefix.resolve())
163
+ completed = subprocess.run(
164
+ [str(configure), f"--prefix={prefix_arg}"],
165
+ cwd=str(build_dir),
166
+ capture_output=True,
167
+ text=True,
168
+ check=False,
169
+ )
170
+ if completed.returncode != 0:
171
+ raise SwigProvisionError(
172
+ f"SWIG {version} configure failed:\n{completed.stdout}\n{completed.stderr}"
173
+ )
174
+
175
+ for args in (["make", "-j4"], ["make", "install"]):
176
+ completed = subprocess.run(
177
+ args,
178
+ cwd=str(build_dir),
179
+ capture_output=True,
180
+ text=True,
181
+ check=False,
182
+ )
183
+ if completed.returncode != 0:
184
+ label = " ".join(args)
185
+ raise SwigProvisionError(
186
+ f"SWIG {version} {label} failed:\n{completed.stdout}\n{completed.stderr}"
187
+ )
188
+
189
+ swig_exe = install_prefix / "bin" / "swig"
190
+ if not swig_exe.is_file():
191
+ raise SwigProvisionError(f"SWIG {version} install did not produce {swig_exe}")
192
+ if read_swig_version(swig_exe) != version:
193
+ raise SwigProvisionError(f"expected SWIG {version}, got {read_swig_version(swig_exe)}")
194
+ return swig_exe
pyswig_dev/test_all.py ADDED
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env python3
2
+ """Run the pyswig test suite on every configured Python version."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import subprocess
9
+
10
+ from pyswig_dev.config import coverage_python_version, repo_root, test_python_versions
11
+ from pyswig_dev.ssl_util import configure_ssl_environment
12
+ from pyswig_dev.uv_util import uv_command, uv_install_hint
13
+
14
+ ROOT = repo_root()
15
+
16
+
17
+ def _base_env() -> dict[str, str]:
18
+ return configure_ssl_environment(os.environ.copy())
19
+
20
+
21
+ def python_is_available(uv: list[str], python_version: str) -> bool:
22
+ completed = subprocess.run(
23
+ [*uv, "python", "find", python_version],
24
+ cwd=ROOT,
25
+ capture_output=True,
26
+ text=True,
27
+ check=False,
28
+ env=_base_env(),
29
+ )
30
+ return completed.returncode == 0
31
+
32
+
33
+ def run_pytest(
34
+ uv: list[str],
35
+ python_version: str,
36
+ *,
37
+ with_coverage: bool,
38
+ pytest_args: list[str],
39
+ ) -> int:
40
+ command = [*uv, "run", "--python", python_version, "--", "python", "-m", "pytest"]
41
+ if with_coverage:
42
+ command.extend(
43
+ [
44
+ "--cov=pyswig",
45
+ "--cov-report=term-missing",
46
+ "--cov-report=html:public/htmlcov",
47
+ ]
48
+ )
49
+ command.extend(pytest_args)
50
+ print(
51
+ f"\n==> pytest on Python {python_version}" + (" (with coverage)" if with_coverage else "")
52
+ )
53
+ completed = subprocess.run(command, cwd=ROOT, check=False, env=_base_env())
54
+ return completed.returncode
55
+
56
+
57
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
58
+ parser = argparse.ArgumentParser(description=__doc__)
59
+ parser.add_argument(
60
+ "--write-badges",
61
+ action="store_true",
62
+ help="Write public/pythonXYZ-badge.svg files for each tested version",
63
+ )
64
+ parser.add_argument(
65
+ "--pytest-args",
66
+ nargs=argparse.REMAINDER,
67
+ default=["-q"],
68
+ help="Extra arguments forwarded to pytest (default: -q)",
69
+ )
70
+ return parser.parse_args(argv)
71
+
72
+
73
+ def main(argv: list[str] | None = None) -> int:
74
+ from scripts.write_version_badge import write_badge
75
+
76
+ args = parse_args(argv)
77
+ uv = uv_command(install_hint=uv_install_hint())
78
+ versions = test_python_versions()
79
+ coverage_version = coverage_python_version()
80
+ failures: list[str] = []
81
+ skipped: list[str] = []
82
+
83
+ if args.write_badges:
84
+ (ROOT / "public").mkdir(parents=True, exist_ok=True)
85
+
86
+ for version in versions:
87
+ if not python_is_available(uv, version):
88
+ print(f"[skip] Python {version} not available to uv; skipping tests")
89
+ skipped.append(version)
90
+ continue
91
+
92
+ if args.write_badges:
93
+ write_badge(python_version=version, status="pending")
94
+
95
+ exit_code = run_pytest(
96
+ uv,
97
+ version,
98
+ with_coverage=(version == coverage_version),
99
+ pytest_args=args.pytest_args,
100
+ )
101
+
102
+ if args.write_badges:
103
+ write_badge(
104
+ python_version=version,
105
+ status="ok" if exit_code == 0 else "failed",
106
+ )
107
+
108
+ if exit_code != 0:
109
+ failures.append(version)
110
+
111
+ print("")
112
+ if skipped:
113
+ print(f"[skip] unavailable Python version(s): {', '.join(skipped)}")
114
+ if failures:
115
+ print(f"[failed] Python version(s): {', '.join(failures)}")
116
+ return 1
117
+
118
+ print("[ok] all configured Python versions passed")
119
+ return 0
120
+
121
+
122
+ if __name__ == "__main__":
123
+ raise SystemExit(main())
pyswig_dev/uv_util.py ADDED
@@ -0,0 +1,77 @@
1
+ """Resolve how to invoke uv on the current machine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def find_uv_command() -> list[str] | None:
13
+ """Return argv prefix to run uv when it is already available."""
14
+ binary = shutil.which("uv")
15
+ if binary:
16
+ return [binary]
17
+
18
+ module_check = subprocess.run(
19
+ [sys.executable, "-m", "uv", "--version"],
20
+ text=True,
21
+ capture_output=True,
22
+ check=False,
23
+ )
24
+ if module_check.returncode == 0:
25
+ return [sys.executable, "-m", "uv"]
26
+
27
+ return None
28
+
29
+
30
+ def describe_uv_command(command: list[str]) -> str:
31
+ """Human-readable location for log messages."""
32
+ if len(command) == 1:
33
+ return command[0]
34
+ return f"{command[0]} -m uv"
35
+
36
+
37
+ def uv_install_hint() -> str:
38
+ return (
39
+ "uv is required but was not found after automatic installation attempts.\n"
40
+ "Install manually, then re-run: pyswig-dev setup\n"
41
+ " - pip (user): python -m pip install --user uv\n"
42
+ " - Official installer: https://docs.astral.sh/uv/getting-started/installation/"
43
+ )
44
+
45
+
46
+ def uv_command(*, install_hint: str) -> list[str]:
47
+ """Return argv prefix to run uv, or exit with install_hint."""
48
+ found = find_uv_command()
49
+ if found:
50
+ return found
51
+ raise SystemExit(install_hint)
52
+
53
+
54
+ def augment_path_for_uv() -> None:
55
+ """Add common uv install locations to PATH for the current process."""
56
+ candidates: list[Path] = [
57
+ Path.home() / ".local" / "bin",
58
+ Path.home() / ".cargo" / "bin",
59
+ ]
60
+ if sys.platform == "win32":
61
+ roaming = os.environ.get("APPDATA")
62
+ if roaming:
63
+ scripts = (
64
+ Path(roaming)
65
+ / "Python"
66
+ / f"Python{sys.version_info.major}{sys.version_info.minor}"
67
+ / "Scripts"
68
+ )
69
+ candidates.append(scripts)
70
+ local_app = os.environ.get("LOCALAPPDATA")
71
+ if local_app:
72
+ candidates.append(Path(local_app) / "uv")
73
+
74
+ existing = os.environ.get("PATH", "")
75
+ additions = [str(path) for path in candidates if path.is_dir()]
76
+ if additions:
77
+ os.environ["PATH"] = os.pathsep.join(additions + [existing])
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyswig-dev
3
+ Version: 0.2.0
4
+ Summary: Development tooling for PySwig wrapper authors
5
+ Author-email: Michel Gillet <michel.gillet@libesys.org>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://pyswig.org
8
+ Project-URL: Repository, https://gitlab.com/libesys/tools/pyswig.git
9
+ Keywords: swig,development,tooling
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Topic :: Software Development :: Build Tools
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3 :: Only
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE.txt
19
+ Requires-Dist: pyswig==0.2.0
20
+ Requires-Dist: tomli>=2.0; python_version < "3.11"
21
+ Dynamic: license-file
22
+
23
+ # pyswig-dev
24
+
25
+ Development tooling for [PySwig](https://pyswig.org) wrapper authors:
26
+
27
+ - `pyswig-dev setup` — bootstrap uv, Python versions, `.venv`, pre-commit hooks
28
+ - `pyswig-dev test-all` — run pytest on every configured Python version
29
+
30
+ Install from PyPI (pulls **pyswig** automatically):
31
+
32
+ ```bash
33
+ pip install pyswig-dev
34
+ ```
35
+
36
+ From a git clone (editable, for PySwig contributors):
37
+
38
+ ```bash
39
+ pip install -e packages/pyswig[contributor] -e packages/pyswig-dev
40
+ pyswig-dev setup
41
+ ```
42
+
43
+ Release versions are lockstep with **pyswig** (same tag, e.g. `0.1.4`).
@@ -0,0 +1,14 @@
1
+ pyswig_dev/__init__.py,sha256=oy2BLOPxyVMChKTlmdJd16ArMh8MK47d8bE6njuPiFw,132
2
+ pyswig_dev/cli.py,sha256=mzc3ru577QzDnd-u8PTzlbt8Pn8qIGK78mpzJLJnZdY,816
3
+ pyswig_dev/config.py,sha256=j0SeiS6j9S8xGWcRL-AgZOfGQAp69luTuN6A_Jf2LyQ,3811
4
+ pyswig_dev/setup_env.py,sha256=VSgprziFpO0tuiuBA1M4qj84YFA49CE7axmtvNTcKRw,13164
5
+ pyswig_dev/ssl_util.py,sha256=R6o3y-8woQSEn8UeC-6BsOs7SoLrQruhdzs0P56umAY,4209
6
+ pyswig_dev/swig_provision.py,sha256=teK0yse5CSy9hdUbYq50oWp669A_S4RagVlhIHA7w8s,6483
7
+ pyswig_dev/test_all.py,sha256=TctvwOkhxJ1Uw_QNz0Cc-sHtuo3B9FEbhMXCI3gU0s4,3490
8
+ pyswig_dev/uv_util.py,sha256=0O70jdo4rmzo2EAEQQ5ChXWMfj9Wyem2kBSa_FnIGNE,2259
9
+ pyswig_dev-0.2.0.dist-info/licenses/LICENSE.txt,sha256=7HC5WkxCB_q7rDY3pCKS9FBnyIrhuPCb9SNWTm1bjgw,1069
10
+ pyswig_dev-0.2.0.dist-info/METADATA,sha256=F3pKrDjK9XCepMt8UybmTdgnBfL3VG6duzsrLaTbQ4c,1391
11
+ pyswig_dev-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ pyswig_dev-0.2.0.dist-info/entry_points.txt,sha256=4OxDafhwtYqQH7j2Y_y3bssEeSVDNjoYaVXHuvhInpw,51
13
+ pyswig_dev-0.2.0.dist-info/top_level.txt,sha256=YPpoCGOJ2sZ2qYVjiYhDH42KP5vcy4RAfg0St726AkM,11
14
+ pyswig_dev-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pyswig-dev = pyswig_dev.cli:main
@@ -0,0 +1,20 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 Michel Gillet
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pyswig_dev