Skip to content

Commit ec69ee6

Browse files
committed
fix: tests
1 parent a7d4e1d commit ec69ee6

12 files changed

Lines changed: 1001 additions & 1229 deletions

File tree

.github/workflows/test.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ jobs:
2424
steps:
2525
- name: Checkout code
2626
uses: actions/checkout@v4
27+
with:
28+
submodules: recursive
2729

2830
- name: Cache uv downloads/builds
2931
uses: actions/cache@v5

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
*.dylib
1313
build/
1414
dist/
15-
mapnik/paths.py
15+
packaging/mapnik/paths.py
1616
*.egg-info/
1717
.eggs/
1818
.mason/

Dockerfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
FROM python:3.14-rc-bookworm
22

3+
# Build-time proxy support (affects apt/uv/etc. during docker build)
4+
ARG http_proxy
5+
ARG https_proxy
6+
ARG no_proxy
7+
ENV http_proxy=${http_proxy} \
8+
https_proxy=${https_proxy} \
9+
no_proxy=${no_proxy} \
10+
HTTP_PROXY=${http_proxy} \
11+
HTTPS_PROXY=${https_proxy} \
12+
NO_PROXY=${no_proxy}
13+
314
# 添加 Debian sid 仓库以获取 Mapnik 4.2
415
RUN echo "deb http://deb.debian.org/debian sid main" >> /etc/apt/sources.list.d/sid.list && \
516
echo 'Package: *\nPin: release a=sid\nPin-Priority: 100' > /etc/apt/preferences.d/sid

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ Once you have installed you can test the package by running:
2020
pytest test/python_tests/
2121
```
2222

23+
## UV Sync via Docker
2324

24-
25-
25+
```
26+
docker run --rm -it \
27+
-e http_proxy -e https_proxy -e no_proxy \
28+
-v "$(pwd):/workspace" \
29+
-w /workspace \
30+
python-mapnik:local \
31+
uv sync --verbose
32+
```

build.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import glob
22
import os
33
from subprocess import Popen, PIPE
4-
from distutils import sysconfig
4+
import sysconfig
55

66
Import('env')
77

@@ -10,15 +10,15 @@ def call(cmd, silent=True):
1010
if not stderr:
1111
return stdin.strip()
1212
elif not silent:
13-
print stderr
13+
print(stderr)
1414

1515

1616
prefix = env['PREFIX']
17-
target_path = os.path.normpath(sysconfig.get_python_lib() + os.path.sep + env['MAPNIK_NAME'])
17+
target_path = os.path.normpath(sysconfig.get_path('purelib') + os.path.sep + env['MAPNIK_NAME'])
1818

1919
py_env = env.Clone()
2020

21-
py_env.Append(CPPPATH = sysconfig.get_python_inc())
21+
py_env.Append(CPPPATH = sysconfig.get_path('include'))
2222

2323
py_env.Append(CPPDEFINES = env['LIBMAPNIK_DEFINES'])
2424

@@ -63,12 +63,12 @@ def call(cmd, silent=True):
6363
if not os.path.exists(env['MAPNIK_NAME']):
6464
os.mkdir(env['MAPNIK_NAME'])
6565

66-
file('mapnik/paths.py','w').write(paths % (env['MAPNIK_LIB_DIR']))
66+
open('mapnik/paths.py', 'w', encoding='utf-8').write(paths % (env['MAPNIK_LIB_DIR']))
6767

6868
# force open perms temporarily so that `sudo scons install`
6969
# does not later break simple non-install non-sudo rebuild
7070
try:
71-
os.chmod('mapnik/paths.py',0666)
71+
os.chmod('mapnik/paths.py', 0o666)
7272
except: pass
7373

7474
# install the shared object beside the module directory
@@ -89,7 +89,7 @@ def call(cmd, silent=True):
8989
env.Command( targetp, 'mapnik/paths.py',
9090
[
9191
Copy("$TARGET","$SOURCE"),
92-
Chmod("$TARGET", 0644),
92+
Chmod("$TARGET", 0o644),
9393
])
9494

9595
if 'uninstall' not in COMMAND_LINE_TARGETS:

packaging/mapnik/__init__.py

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,19 +67,31 @@ def bootstrap_env():
6767
# In some build/test setups the compiled extension can be imported under the
6868
# top-level name `_mapnik` (e.g. due to sys.path/build output layout) and then
6969
# again as `mapnik._mapnik`. Loading the same pybind11 extension twice in one
70-
# interpreter can raise errors like:
71-
# pybind11::native_enum<...>("CompositeOp") is already registered!
72-
# If a compatible `_mapnik` is already loaded, alias it to the canonical
73-
# `mapnik._mapnik` name before importing symbols.
70+
# interpreter can raise "already registered" errors for pybind11 types/enums.
71+
#
72+
# To avoid a second load, if `_mapnik` is already present, alias it to the
73+
# canonical `mapnik._mapnik` name before importing.
7474
_canonical_ext_name = f"{__name__}._mapnik"
75-
if _canonical_ext_name in sys.modules:
76-
pass
77-
elif "_mapnik" in sys.modules:
78-
_candidate = sys.modules["_mapnik"]
79-
# Heuristic guard: only alias if it looks like our Mapnik extension.
80-
if hasattr(_candidate, "Map") and hasattr(_candidate, "version_string"):
81-
sys.modules[_canonical_ext_name] = _candidate
82-
75+
if _canonical_ext_name not in sys.modules and "_mapnik" in sys.modules:
76+
# If a top-level `_mapnik` was imported first, alias it to the canonical
77+
# `mapnik._mapnik` name so Python reuses the same extension module object
78+
# rather than attempting a second load.
79+
sys.modules[_canonical_ext_name] = sys.modules["_mapnik"]
80+
81+
_prev_err = getattr(sys, "_python_mapnik_ext_import_error", None)
82+
if _prev_err is not None:
83+
# Avoid repeated attempts to load the extension in the same interpreter
84+
# after a failure (which can lead to confusing secondary errors like
85+
# "already registered" from pybind11).
86+
raise ImportError(str(_prev_err)) from None
87+
88+
try:
89+
from . import _mapnik as _ext
90+
except ImportError as e:
91+
setattr(sys, "_python_mapnik_ext_import_error", e)
92+
raise
93+
# Ensure subsequent `import _mapnik` reuses the already-loaded extension.
94+
sys.modules.setdefault("_mapnik", _ext)
8395
from ._mapnik import *
8496

8597
def Shapefile(**keywords):

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ maintainers = [
2222
{name= "Artem Pavlenko", email = "artem@mapnik.org"},
2323
]
2424

25-
requires-python = ">= 3.8"
25+
requires-python = ">= 3.12"
2626

2727
[project.optional-dependencies]
2828
test = [
@@ -45,4 +45,4 @@ testpaths = [
4545

4646
[tool.uv]
4747
# Pin uv version for CI/dev consistency (used by astral-sh/setup-uv).
48-
required-version = "==0.9.25"
48+
required-version = ">=0.9.0"

setup.py

Lines changed: 139 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,152 @@
11
#! /usr/bin/env python3
22

33
import os
4+
import shlex
45
import subprocess
56
import sys
67

78
from pybind11.setup_helpers import Pybind11Extension, build_ext
89
from setuptools import find_packages, setup
910

1011

11-
def check_output(args):
12-
output = subprocess.check_output(args).decode()
13-
return output.rstrip("\n")
12+
class MapnikBuildConfig:
13+
"""
14+
Centralizes build configuration for the Mapnik pybind11 extension.
1415
16+
Keep this file import-safe for setuptools/pip: do all external discovery
17+
(pkg-config) inside this class, not at module import time.
18+
"""
1519

16-
linkflags = []
17-
lib_path = os.path.join(
18-
check_output(["pkg-config", "--variable=prefix", "libmapnik"]), "lib"
19-
)
20-
linkflags.extend(check_output(["pkg-config", "--libs", "libmapnik"]).split(" "))
21-
22-
# Dynamically make the mapnik/paths.py file
23-
f_paths = open("packaging/mapnik/paths.py", "w")
24-
f_paths.write("import os\n")
25-
f_paths.write("\n")
26-
27-
input_plugin_path = check_output(["pkg-config", "--variable=plugins_dir", "libmapnik"])
28-
font_path = check_output(["pkg-config", "--variable=fonts_dir", "libmapnik"])
29-
30-
if os.environ.get("LIB_DIR_NAME"):
31-
mapnik_lib_path = lib_path + os.environ.get("LIB_DIR_NAME")
32-
else:
33-
mapnik_lib_path = lib_path + "/mapnik"
34-
f_paths.write("mapniklibpath = '{path}'\n".format(path=mapnik_lib_path))
35-
f_paths.write("inputpluginspath = '{path}'\n".format(path=input_plugin_path))
36-
f_paths.write("fontscollectionpath = '{path}'\n".format(path=font_path))
37-
f_paths.write("__all__ = [mapniklibpath,inputpluginspath,fontscollectionpath]\n")
38-
f_paths.close()
39-
40-
extra_comp_args = check_output(["pkg-config", "--cflags", "libmapnik"]).split(" ")
41-
extra_comp_args = list(
42-
filter(lambda arg: arg != "-fvisibility=hidden", extra_comp_args)
43-
)
20+
def __init__(self, pkg_name: str = "libmapnik"):
21+
self.pkg_name = pkg_name
22+
self.linkflags: list[str] = []
23+
self.extra_comp_args: list[str] = []
24+
self.mapnik_lib_path: str = ""
25+
self.input_plugin_path: str = ""
26+
self.font_path: str = ""
27+
28+
@staticmethod
29+
def _check_output(args: list[str]) -> str:
30+
output = subprocess.check_output(args).decode()
31+
return output.rstrip("\n")
32+
33+
def _pkg_config_var(self, var_name: str) -> str:
34+
return self._check_output(["pkg-config", "--variable=" + var_name, self.pkg_name])
35+
36+
@staticmethod
37+
def _split_flags(s: str) -> list[str]:
38+
# pkg-config/mapnik-config return shell-like strings; shlex handles quoted paths safely.
39+
return [arg for arg in shlex.split(s) if arg]
40+
41+
def _ensure_cpp_std(self) -> None:
42+
"""
43+
Some environments don't inject a C++ standard in pkg-config/mapnik-config flags.
44+
This project uses C++17 features (e.g., `template <auto Key>`), so ensure C++17.
45+
"""
46+
# Always force C++17 as the last -std flag so it wins over older standards.
47+
# (GCC/Clang take the last -std=... argument.)
48+
if not any(arg.startswith("/std:") for arg in self.extra_comp_args):
49+
self.extra_comp_args.append("-std=c++17")
50+
51+
def _discover_with_pkg_config(self) -> None:
52+
# Linker flags / library location.
53+
prefix = self._pkg_config_var("prefix")
54+
lib_path = os.path.join(prefix, "lib")
55+
self.linkflags.extend(self._split_flags(self._check_output(["pkg-config", "--libs", self.pkg_name])))
56+
57+
# Runtime data locations.
58+
self.input_plugin_path = self._pkg_config_var("plugins_dir")
59+
self.font_path = self._pkg_config_var("fonts_dir")
60+
61+
lib_dir_name = os.environ.get("LIB_DIR_NAME")
62+
if lib_dir_name:
63+
self.mapnik_lib_path = lib_path + lib_dir_name
64+
else:
65+
self.mapnik_lib_path = lib_path + "/mapnik"
66+
67+
# Compiler flags.
68+
extra_comp_args = self._split_flags(self._check_output(["pkg-config", "--cflags", self.pkg_name]))
69+
self.extra_comp_args = [arg for arg in extra_comp_args if arg != "-fvisibility=hidden"]
70+
71+
def _mapnik_config(self) -> str:
72+
# Allow pinning a specific mapnik-config (useful in CI / non-standard prefixes).
73+
return os.environ.get("MAPNIK_CONFIG", "mapnik-config")
74+
75+
def _mapnik_config_try_flag(self, flag_candidates: list[str]) -> str:
76+
"""
77+
Try mapnik-config with one of the provided flags and return the first
78+
successful (non-empty) output. Returns "" if none work.
79+
"""
80+
cmd = self._mapnik_config()
81+
for flag in flag_candidates:
82+
try:
83+
out = self._check_output([cmd, flag]).strip()
84+
except (FileNotFoundError, subprocess.CalledProcessError):
85+
continue
86+
if out:
87+
return out
88+
return ""
89+
90+
def _discover_with_mapnik_config(self) -> None:
91+
cmd = self._mapnik_config()
92+
prefix = self._check_output([cmd, "--prefix"])
93+
lib_path = os.path.join(prefix, "lib")
94+
95+
# flags
96+
self.linkflags.extend(self._split_flags(self._check_output([cmd, "--libs"])))
97+
extra_comp_args = self._split_flags(self._check_output([cmd, "--cflags"]))
98+
self.extra_comp_args = [arg for arg in extra_comp_args if arg != "-fvisibility=hidden"]
99+
100+
# runtime locations (best-effort: flags vary slightly across mapnik versions/distros)
101+
self.input_plugin_path = self._mapnik_config_try_flag(
102+
["--input-plugins", "--input-plugins-dir", "--input-plugins-path"]
103+
)
104+
self.font_path = self._mapnik_config_try_flag(["--fonts", "--fonts-dir", "--fonts-path"])
105+
106+
lib_dir_name = os.environ.get("LIB_DIR_NAME")
107+
if lib_dir_name:
108+
self.mapnik_lib_path = lib_path + lib_dir_name
109+
else:
110+
self.mapnik_lib_path = lib_path + "/mapnik"
111+
112+
def discover(self) -> None:
113+
# Prefer pkg-config, but fall back to mapnik-config (common on some distros/builds).
114+
try:
115+
self._discover_with_pkg_config()
116+
except (FileNotFoundError, subprocess.CalledProcessError):
117+
self._discover_with_mapnik_config()
118+
119+
if not self.input_plugin_path or not self.font_path:
120+
raise RuntimeError(
121+
"Failed to discover Mapnik runtime paths. "
122+
"Tried pkg-config variables (plugins_dir/fonts_dir) and mapnik-config flags "
123+
"(--input-plugins/--fonts). You can set MAPNIK_CONFIG to point to mapnik-config."
124+
)
125+
126+
self._ensure_cpp_std()
127+
128+
# Platform-specific linker flags.
129+
if sys.platform != "darwin":
130+
self.linkflags.append("-lrt")
131+
self.linkflags.append("-Wl,-z,origin")
132+
self.linkflags.append("-Wl,-rpath=$ORIGIN/lib")
133+
134+
self.linkflags = [arg for arg in self.linkflags if arg]
135+
136+
def write_paths_py(self, target_file: str = "packaging/mapnik/paths.py") -> None:
137+
os.makedirs(os.path.dirname(target_file), exist_ok=True)
138+
with open(target_file, "w", encoding="utf-8") as f_paths:
139+
f_paths.write("import os\n\n")
140+
f_paths.write(f"mapniklibpath = {self.mapnik_lib_path!r}\n")
141+
f_paths.write(f"inputpluginspath = {self.input_plugin_path!r}\n")
142+
f_paths.write(f"fontscollectionpath = {self.font_path!r}\n")
143+
# __all__ should be a list of names (strings), not the values.
144+
f_paths.write('__all__ = ["mapniklibpath", "inputpluginspath", "fontscollectionpath"]\n')
44145

45-
if sys.platform == "darwin":
46-
pass
47-
else:
48-
linkflags.append("-lrt")
49-
linkflags.append("-Wl,-z,origin")
50-
linkflags.append("-Wl,-rpath=$ORIGIN/lib")
51146

52-
extra_comp_args = list(filter(lambda arg: arg != "", extra_comp_args))
53-
linkflags = list(filter(lambda arg: arg != "", linkflags))
147+
cfg = MapnikBuildConfig("libmapnik")
148+
cfg.discover()
149+
cfg.write_paths_py()
54150

55151
ext_modules = [
56152
Pybind11Extension(
@@ -104,8 +200,9 @@ def check_output(args):
104200
"src/mapnik_shield_symbolizer.cpp",
105201
"src/mapnik_group_symbolizer.cpp",
106202
],
107-
extra_compile_args=extra_comp_args,
108-
extra_link_args=linkflags,
203+
cxx_std=17,
204+
extra_compile_args=cfg.extra_comp_args,
205+
extra_link_args=cfg.linkflags,
109206
)
110207
]
111208

@@ -116,7 +213,7 @@ def check_output(args):
116213

117214
setup(
118215
name="mapnik",
119-
version="4.0.0.dev",
216+
version="4.2.0.dev",
120217
packages=find_packages(where="packaging"),
121218
package_dir={"": "packaging"},
122219
package_data={

src/mapnik_coord.cpp

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,14 @@ void export_coord(py::module const& m)
4141
"Gets or sets the x/lon coordinate of the point.\n")
4242
.def_readwrite("y", &coord<double,2>::y,
4343
"Gets or sets the y/lat coordinate of the point.\n")
44-
.def(py::self == py::self) // __eq__
44+
// mapnik::coord<T,2>::operator== is non-const in some Mapnik versions,
45+
// which breaks pybind11's operator helper (it compares `const` values).
46+
// Define __eq__ explicitly to avoid relying on the C++ operator signature.
47+
.def("__eq__",
48+
[](coord<double,2> const& a, coord<double,2> const& b) {
49+
return a.x == b.x && a.y == b.y;
50+
},
51+
py::is_operator())
4552
.def(py::self + py::self) //__add__
4653
.def(py::self + float())
4754
.def(float() + py::self)

0 commit comments

Comments
 (0)