pyswig-dev 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.
- pyswig_dev-0.2.0/LICENSE.txt +20 -0
- pyswig_dev-0.2.0/PKG-INFO +43 -0
- pyswig_dev-0.2.0/README.md +21 -0
- pyswig_dev-0.2.0/pyproject.toml +39 -0
- pyswig_dev-0.2.0/setup.cfg +4 -0
- pyswig_dev-0.2.0/src/pyswig_dev/__init__.py +5 -0
- pyswig_dev-0.2.0/src/pyswig_dev/cli.py +31 -0
- pyswig_dev-0.2.0/src/pyswig_dev/config.py +111 -0
- pyswig_dev-0.2.0/src/pyswig_dev/setup_env.py +415 -0
- pyswig_dev-0.2.0/src/pyswig_dev/ssl_util.py +122 -0
- pyswig_dev-0.2.0/src/pyswig_dev/swig_provision.py +194 -0
- pyswig_dev-0.2.0/src/pyswig_dev/test_all.py +123 -0
- pyswig_dev-0.2.0/src/pyswig_dev/uv_util.py +77 -0
- pyswig_dev-0.2.0/src/pyswig_dev.egg-info/PKG-INFO +43 -0
- pyswig_dev-0.2.0/src/pyswig_dev.egg-info/SOURCES.txt +17 -0
- pyswig_dev-0.2.0/src/pyswig_dev.egg-info/dependency_links.txt +1 -0
- pyswig_dev-0.2.0/src/pyswig_dev.egg-info/entry_points.txt +2 -0
- pyswig_dev-0.2.0/src/pyswig_dev.egg-info/requires.txt +4 -0
- pyswig_dev-0.2.0/src/pyswig_dev.egg-info/top_level.txt +1 -0
|
@@ -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,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,21 @@
|
|
|
1
|
+
# pyswig-dev
|
|
2
|
+
|
|
3
|
+
Development tooling for [PySwig](https://pyswig.org) wrapper authors:
|
|
4
|
+
|
|
5
|
+
- `pyswig-dev setup` — bootstrap uv, Python versions, `.venv`, pre-commit hooks
|
|
6
|
+
- `pyswig-dev test-all` — run pytest on every configured Python version
|
|
7
|
+
|
|
8
|
+
Install from PyPI (pulls **pyswig** automatically):
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install pyswig-dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
From a git clone (editable, for PySwig contributors):
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install -e packages/pyswig[contributor] -e packages/pyswig-dev
|
|
18
|
+
pyswig-dev setup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Release versions are lockstep with **pyswig** (same tag, e.g. `0.1.4`).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyswig-dev"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Development tooling for PySwig wrapper authors"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE.txt"]
|
|
13
|
+
authors = [{ name = "Michel Gillet", email = "michel.gillet@libesys.org" }]
|
|
14
|
+
keywords = ["swig", "development", "tooling"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 5 - Production/Stable",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Topic :: Software Development :: Build Tools",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Operating System :: OS Independent",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pyswig==0.2.0",
|
|
25
|
+
"tomli>=2.0; python_version < '3.11'",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
pyswig-dev = "pyswig_dev.cli:main"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://pyswig.org"
|
|
33
|
+
Repository = "https://gitlab.com/libesys/tools/pyswig.git"
|
|
34
|
+
|
|
35
|
+
[tool.setuptools.packages.find]
|
|
36
|
+
where = ["src"]
|
|
37
|
+
|
|
38
|
+
[tool.setuptools.dynamic]
|
|
39
|
+
version = { attr = "pyswig_dev.__version__" }
|
|
@@ -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())
|
|
@@ -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())
|
|
@@ -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
|
|
@@ -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())
|
|
@@ -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,17 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/pyswig_dev/__init__.py
|
|
5
|
+
src/pyswig_dev/cli.py
|
|
6
|
+
src/pyswig_dev/config.py
|
|
7
|
+
src/pyswig_dev/setup_env.py
|
|
8
|
+
src/pyswig_dev/ssl_util.py
|
|
9
|
+
src/pyswig_dev/swig_provision.py
|
|
10
|
+
src/pyswig_dev/test_all.py
|
|
11
|
+
src/pyswig_dev/uv_util.py
|
|
12
|
+
src/pyswig_dev.egg-info/PKG-INFO
|
|
13
|
+
src/pyswig_dev.egg-info/SOURCES.txt
|
|
14
|
+
src/pyswig_dev.egg-info/dependency_links.txt
|
|
15
|
+
src/pyswig_dev.egg-info/entry_points.txt
|
|
16
|
+
src/pyswig_dev.egg-info/requires.txt
|
|
17
|
+
src/pyswig_dev.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyswig_dev
|