diff --git a/proj4rs-clib/Makefile.toml b/proj4rs-clib/Makefile.toml index 0cf4fc1..536938b 100644 --- a/proj4rs-clib/Makefile.toml +++ b/proj4rs-clib/Makefile.toml @@ -36,3 +36,33 @@ dependencies = ["build-release", "cbindgen"] [tasks.release] dependencies = ["deb"] + + +[tasks."python.lint"] +command = "ruff" +args = [ + "check", + "--output-format", "concise", + "python", +] + + +[tasks."python.lint-fix"] +command = "ruff" +args = [ + "check", + "--preview", + "--fix", + "python", +] + + +[tasks."python.typing"] +command = "mypy" +args = ["python"] + + +[tasks."python.test"] +command = "pytest" +args = [ "-v", "python/tests"] +dependencies = ["python.lint", "python.typing"] diff --git a/proj4rs-clib/pyproject.toml b/proj4rs-clib/pyproject.toml new file mode 100644 index 0000000..c1056d6 --- /dev/null +++ b/proj4rs-clib/pyproject.toml @@ -0,0 +1,53 @@ +# Use Maturin https://www.maturin.rs/ +[build-system] +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "proj4rs" +requires-python = ">=3.12" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: Implementation :: PyPy", +] +dependencies = ["cffi"] +dynamic = ["version"] + +[tool.maturin] +bindings = "cffi" +python-source = "python" +module-name = "proj4rs._proj4rs" + +[tool.ruff] +# Ruff configuration +# See https://docs.astral.sh/ruff/configuration/ +line-length = 120 +target-version = "py312" +extend-exclude = ["python/proj4rs/_proj4rs"] + +[tool.ruff.format] +indent-style = "space" + +[tool.ruff.lint] +extend-select = ["E", "F", "I", "ANN", "W", "T", "COM", "RUF"] +ignore = ["ANN002", "ANN003"] + +[tool.ruff.lint.per-file-ignores] +"python/tests/*" = ["T201"] + +[tool.ruff.lint.isort] +lines-between-types = 1 + +[tool.ruff.lint.flake8-annotations] +ignore-fully-untyped = true +suppress-none-returning = true +suppress-dummy-args = true + +[tool.mypy] +python_version = "3.12" +allow_redefinition = true + +[[tool.mypy.overrides]] +module = "_cffi_backend" +ignore_missing_imports = true diff --git a/proj4rs-clib/python/.gitignore b/proj4rs-clib/python/.gitignore new file mode 100644 index 0000000..1b8d935 --- /dev/null +++ b/proj4rs-clib/python/.gitignore @@ -0,0 +1 @@ +proj4rs/_proj4rs diff --git a/proj4rs-clib/python/proj4rs/__init__.py b/proj4rs-clib/python/proj4rs/__init__.py new file mode 100644 index 0000000..da3d9f7 --- /dev/null +++ b/proj4rs-clib/python/proj4rs/__init__.py @@ -0,0 +1,286 @@ +from array import array +from collections import abc +from typing import Any, Tuple, TypeVar, Union, overload + +from ._proj4rs import ffi, lib + + +class Proj: + + def __init__(self, defn: str): + _defn = ffi.new("char[]", defn.encode()) + self._cdata = lib.proj4rs_proj_new(_defn) + + def __del__(self): + lib.proj4rs_proj_delete(self._cdata) + + @property + def projname(self) -> str: + _rv = lib.proj4rs_proj_projname(self._cdata) + return ffi.string(_rv).decode() + + @property + def is_latlong(self) -> bool: + return lib.proj4rs_proj_is_latlong(self._cdata) + + @property + def is_geocent(self) -> bool: + return lib.proj4rs_proj_is_geocent(self._cdata) + + @property + def axis(self) -> bytes: + _rv = lib.proj4rs_proj_axis(self._cdata) + return bytes(ffi.cast("uint8_t[3]", _rv)) + + @property + def is_normalized_axis(self) -> bool: + return lib.proj4rs_proj_is_normalized_axis(self._cdata) + + @property + def to_meter(self) -> float: + return lib.proj4rs_proj_to_meter(self._cdata) + + @property + def units(self) -> str: + _rv = lib.proj4rs_proj_units(self._cdata) + return ffi.string(_rv).decode() + + +def _scalar_to_buffer(x): + return array("d", (float(x),)) + + +def _copy_buffer(x, inplace): + match x: + case array(): + if not inplace or x.typecode != 'd': + x = array("d", x) + case memoryview(): + # Ensure 1 dimensional data + if x.ndim != 1: + raise ValueError("Expecting 1 dimensional array") + if not inplace or x.format != 'd': + x = array("d", x) + case abc.Sequence(): + x = array("d", x) + case _: + raise ValueError("Invalid buffer type") + return x + + +SIZEOF_DOUBLE = ffi.sizeof("double") + + +T = TypeVar('T') + + +class Transform: + + def __init__(self, src: Proj | str, dst: Proj | str): + self._from = Proj(src) if isinstance(src, str) else src + self._to = Proj(dst) if isinstance(dst, str) else dst + + @property + def source(self) -> Proj: + return self._from + + @property + def destination(self) -> Proj: + return self._to + + @overload + def transform( + self, + x: abc.Buffer, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[abc.Buffer, abc.Buffer], + Tuple[abc.Buffer, abc.Buffer, abc.Buffer], + ]: ... + + @overload + def transform( + self, + x: float | int, + y: float | int, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[float, float], + ]: ... + + @overload + def transform( + self, + x: float | int, + y: float | int, + z: float | int, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[float, float, float], + ]: ... + + @overload + def transform( + self, + x: list | tuple, + y: list | tuple, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[array, array], + ]: ... + + @overload + def transform( + self, + x: list | tuple, + y: list | tuple, + z: list | tuple, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[array, array, array], + ]: ... + + @overload + def transform( + self, + x: abc.Buffer, + y: abc.Buffer, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[abc.Buffer, abc.Buffer], + ]: ... + + @overload + def transform( + self, + x: abc.Buffer, + y: abc.Buffer, + z: abc.Buffer, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[abc.Buffer, abc.Buffer, abc.Buffer], + ]: ... + + def transform( + self, + x: T, + y: T | None = None, + z: T | None = None, + *, + convert: bool = True, + inplace: bool = False, + ) -> Union[ + Tuple[Any, Any], + Tuple[Any, Any, Any], + ]: + """ Transform coordinates + + Parameters + ---------- + + xx: scalar or sequence, input x coordinate(s) + yy: scalar or sequence, optional, input x coordinate(s) + zz: scalar or sequence, optional, input x coordinate(s) + + convert: if true, assume that coordinates are in degrees and the transformation + will convert data accordingly + inplace: if true, convert data inplace if the input data implement the Buffer + protocol. The buffer must be writable + + Returns + ------- + A tuple of buffer objects in the case the input is a Sequence, + a tuple of float otherwise. + + If inplace is true and input is a Buffer, the input object is returned. + """ + match (x, y, z): + case (abc.Buffer(), None, None): + scalar = False + m = memoryview(x) + if m.ndim != 2: + raise ValueError("Expecting two-dimensional buffer") + if m.shape is None: + raise ValueError("Invalid buffer shape (None)") + size, dim = m.shape + if dim != 2 and dim != 3: + raise ValueError(f"Expecting geometry dimensions of 2 or 3, found {dim}") + # Flatten buffer + flatten = m.cast('b').cast(m.format) # type: ignore [call-overload] + if not inplace or m.format != 'd' or not m.c_contiguous: + _x = array('d', flatten[0::dim]) + _y = array('d', flatten[1::dim]) + _z = array('d', flatten[2::dim]) if dim > 2 else None + else: + _x = flatten[0::dim] + _y = flatten[1::dim] + _z = flatten[2::dim] if dim > 2 else None + + stride = dim * SIZEOF_DOUBLE + + case (abc.Sequence(), abc.Sequence(), _): + scalar = False + if len(y) != len(x) and (not z or len(z) != len(x)): # type: ignore [arg-type] + raise ValueError("Arrays must have the same length") + _x = _copy_buffer(x, inplace) + _y = _copy_buffer(y, inplace) + _z = _copy_buffer(z, inplace) if z else None + size = len(_x) + stride = SIZEOF_DOUBLE + case (abc.Buffer(), abc.Buffer(), _): + scalar = False + mx = memoryview(x) + my = memoryview(y) + mz = memoryview(z) if z else None # type: ignore [arg-type] + if len(my) != len(mx) and (not mz or len(mz) != len(mx)): # + raise ValueError("Buffers must have same length") + _x = _copy_buffer(mx, inplace) + _y = _copy_buffer(my, inplace) + _z = _copy_buffer(mz, inplace) if mz else None + size = len(_x) + stride = SIZEOF_DOUBLE + case _: + scalar = True + _x = _scalar_to_buffer(x) + _y = _scalar_to_buffer(y) + _z = _scalar_to_buffer(z) if z else None + size = 1 + stride = SIZEOF_DOUBLE + + _t = "double[]" + + _xx = ffi.from_buffer(_t, _x, require_writable=True) + _yy = ffi.from_buffer(_t, _y, require_writable=True) + _zz = ffi.from_buffer(_t, _z, require_writable=True) if z else ffi.NULL + res = lib.proj4rs_transform( + self._from._cdata, + self._to._cdata, + _xx, + _yy, + _zz, + size, + stride, + convert, + ) + if res != 1: + error = lib.proj4rs_last_error() + raise RuntimeError(ffi.string(error).decode()) + + if scalar: + return (_x[0], _y[0], _z[0]) if _z else (_x[0], _y[0]) + else: + return (_x, _y, _z) if _z else (_x, _y) diff --git a/proj4rs-clib/python/tests/test_all.py b/proj4rs-clib/python/tests/test_all.py new file mode 100644 index 0000000..ed5aa42 --- /dev/null +++ b/proj4rs-clib/python/tests/test_all.py @@ -0,0 +1,117 @@ +import proj4rs +import pytest + + +def test_transform_sequence(): + + src = proj4rs.Proj("WGS84") + dst = proj4rs.Proj("+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80") + + print("Src:", src.projname) + print("Dst:", dst.projname) + + x = [15.4213696] + y = [47.0766716] + + trans = proj4rs.Transform(src, dst) + + x, y = trans.transform(x, y) + + print("x =", x) # Should be 4732659.007426 + print("y =", y) # Should be 2677630.726961 + + assert x[0] == pytest.approx(4732659.007426266, 1e-6) + assert y[0] == pytest.approx(2677630.7269610995, 1e-6) + + + +def test_transform_scalar(): + + src = proj4rs.Proj("WGS84") + dst = proj4rs.Proj("+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80") + + print("Src:", src.projname) + print("Dst:", dst.projname) + + x = 15.4213696 + y = 47.0766716 + + print("Transform") + trans = proj4rs.Transform(src, dst) + + x, y = trans.transform(x, y) + + print("x =", x) # Should be 4732659.007426 + print("y =", y) # Should be 2677630.726961 + + assert x == pytest.approx(4732659.007426266, 1e-6) + assert y == pytest.approx(2677630.7269610995, 1e-6) + + +def test_transform_buffer_inplace(): + from array import array + + src = proj4rs.Proj("WGS84") + dst = proj4rs.Proj("+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80") + + print("Src:", src.projname) + print("Dst:", dst.projname) + + x = array('d', [15.4213696]) + y = array('d', [47.0766716]) + + trans = proj4rs.Transform(src, dst) + + xx, yy = trans.transform(x, y, inplace=True) + + print("x =", x) # Should be 4732659.007426 + print("y =", y) # Should be 2677630.726961 + + assert xx is x + assert yy is y + + assert xx[0] == pytest.approx(4732659.007426266, 1e-6) + assert yy[0] == pytest.approx(2677630.7269610995, 1e-6) + + +def test_transform_invalid_buffer(): + from array import array + + src = proj4rs.Proj("WGS84") + dst = proj4rs.Proj("+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80") + + print("Src:", src.projname) + print("Dst:", dst.projname) + + x = array('d', [15.4213696]) + + trans = proj4rs.Transform(src, dst) + + with pytest.raises(ValueError, match="Expecting two-dimensional buffer"): + trans.transform(x, inplace=True) + + +def test_transform_buffer_2d(): + from array import array + + x = array('d', [15.4213696, 47.0766716]) + + # Reshape to a two dimensionnal array + m = memoryview(x).cast('b').cast('d', shape=(1,2)) + print("* shape =", m.shape, "ndim", m.ndim) + + transform = proj4rs.Transform( + "WGS84", + "+proj=laea +lat_0=52 +lon_0=10 +x_0=4321000 +y_0=3210000 +ellps=GRS80", + ).transform + + xx, yy = transform(m, inplace=True) + + print("xx =", list(xx)) + print("yy =", list(yy)) + + assert x[0] == pytest.approx(4732659.007426266, 1e-6) + assert x[1] == pytest.approx(2677630.7269610995, 1e-6) + + + diff --git a/proj4rs-clib/src/lib.rs b/proj4rs-clib/src/lib.rs index efd331b..af2acaa 100644 --- a/proj4rs-clib/src/lib.rs +++ b/proj4rs-clib/src/lib.rs @@ -98,34 +98,25 @@ pub extern "C" fn proj4rs_proj_delete(c_ptr: *mut Proj4rs) { /// Returns the projection name #[no_mangle] pub extern "C" fn proj4rs_proj_projname(c_ptr: *const Proj4rs) -> *const c_char { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - proj.name.as_ptr() as *const c_char - } else { - ptr::null() - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + proj.name.as_ptr() as *const c_char } /// Returns true if the projection is geographic #[no_mangle] pub extern "C" fn proj4rs_proj_is_latlong(c_ptr: *const Proj4rs) -> bool { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - proj.inner.is_latlong() - } else { - false - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + proj.inner.is_latlong() } /// Returns true if the projection is geocentric #[no_mangle] pub extern "C" fn proj4rs_proj_is_geocent(c_ptr: *const Proj4rs) -> bool { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - proj.inner.is_geocent() - } else { - false - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + proj.inner.is_geocent() } /// Return the projection axes @@ -146,44 +137,32 @@ pub extern "C" fn proj4rs_proj_is_geocent(c_ptr: *const Proj4rs) -> bool { /// #[no_mangle] pub extern "C" fn proj4rs_proj_axis(c_ptr: *const Proj4rs) -> *const u8 { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - proj.inner.axis().as_ptr() - } else { - ptr::null() - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + proj.inner.axis().as_ptr() } /// Return true if the axis are noramilized #[no_mangle] pub extern "C" fn proj4rs_proj_is_normalized_axis(c_ptr: *const Proj4rs) -> bool { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - proj.inner.is_normalized_axis() - } else { - false - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + proj.inner.is_normalized_axis() } #[no_mangle] pub extern "C" fn proj4rs_proj_to_meter(c_ptr: *const Proj4rs) -> f64 { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - proj.inner.to_meter() - } else { - 0. - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + proj.inner.to_meter() } /// Return units of the projection (i.e "degrees", "m", "km", ...) #[no_mangle] pub extern "C" fn proj4rs_proj_units(c_ptr: *const Proj4rs) -> *const c_char { - if !c_ptr.is_null() { - let proj: &Proj4rs = unsafe { &*c_ptr }; - to_c_unit(proj.inner.units()).as_ptr() as *const c_char - } else { - ptr::null() - } + assert!(!c_ptr.is_null(), "Null proj pointer"); + let proj: &Proj4rs = unsafe { &*c_ptr }; + to_c_unit(proj.inner.units()).as_ptr() as *const c_char } // ----------------------------