diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c4f82a..efc5f8c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,5 +22,9 @@ jobs: # see https://github.com/pre-commit/pre-commit-hooks/issues/265 SKIP: "no-commit-to-branch" run: poetry run pre-commit run --show-diff-on-failure --color=always + - name: Check whether nqm.irimager stubs match implementation + env: + MYPYPATH: src + run: poetry run stubtest nqm.irimager - name: Test with pytest run: poetry run pytest diff --git a/.gitignore b/.gitignore index b4b9b69..63ed3a5 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,10 @@ ipython_config.py # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock +# The setup.py file is auto-generated by Poetry if you have +# `tool.poetry.build.generate-setup-file = true` set in your pyproject.toml file +setup.py + # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2bf15a9..3b9caab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -44,6 +44,7 @@ repos: [ "-rn", # Only display messages "-sn", # Don't display the score + "--extension-pkg-whitelist=nqm.irimager", # import our custom C extension ] - id: mypy name: mypy diff --git a/README.md b/README.md index a7b8f38..08e83fe 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,21 @@ Tests are written using [`pytest`](https://docs.pytest.org/en/7.2.x/), and can b poetry run pytest ``` +#### Mypy stubtest + +You can use +[Mypy's `stubtest` tool](https://mypy.readthedocs.io/en/stable/stubtest.html) +to automatically check whether the types in a stub file match the +implementation. + +For most Python code, we put the type information directly in the +implementation, so we only need this for C/C++ Python extensions, like the +`nqm.irimager` module. + +```bash +MYPYPATH=src poetry run stubtest nqm.irimager +``` + ### Documentation [Sphinx](https://www.sphinx-doc.org/en/master/index.html) is used to generate diff --git a/build.py b/build.py new file mode 100644 index 0000000..4a1069b --- /dev/null +++ b/build.py @@ -0,0 +1,23 @@ +"""Poetry build script that builds the nqm.irimager Python C++ extension +""" + +from setuptools.extension import Extension + +nqm_irimager_extension = Extension( + "nqm.irimager", + sources=["src/nqm/irimager/irimager.cpp"], + define_macros=[("PY_SSIZE_T_CLEAN", None)], +) + + +def build(setup_kwargs): + """ + This is a callback for poetry used to hook in our extensions. + """ + + setup_kwargs.update( + { + # declare the extension so that setuptools will compile it + "ext_modules": [nqm_irimager_extension], + } + ) diff --git a/poetry.lock b/poetry.lock index fbfa814..ce9c470 100644 --- a/poetry.lock +++ b/poetry.lock @@ -617,6 +617,14 @@ category = "dev" optional = false python-versions = ">=3.6" +[[package]] +name = "types-setuptools" +version = "68.0.0.2" +description = "Typing stubs for setuptools" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "typing-extensions" version = "4.4.0" @@ -679,7 +687,7 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "1.1" python-versions = "^3.8" -content-hash = "26649383148ddf81a9ed80418e9e6cd13b4b3f152287289419203f505e1c48c4" +content-hash = "027faf2c248c4097191b7085f4bdc575b5ebad6c3fef6fcedb40dd46fa4e93b6" [metadata.files] alabaster = [ @@ -1103,6 +1111,10 @@ tomlkit = [ {file = "tomlkit-0.11.6-py3-none-any.whl", hash = "sha256:07de26b0d8cfc18f871aec595fda24d95b08fef89d147caa861939f37230bf4b"}, {file = "tomlkit-0.11.6.tar.gz", hash = "sha256:71b952e5721688937fb02cf9d354dbcf0785066149d2855e44531ebdd2b65d73"}, ] +types-setuptools = [ + {file = "types-setuptools-68.0.0.2.tar.gz", hash = "sha256:fede8b46862dd9fe68a12f11a8444c3d240d11178eba7d584d6f22ca3114b894"}, + {file = "types_setuptools-68.0.0.2-py3-none-any.whl", hash = "sha256:311a14819416716029d1113c7452143e2fa857e6cc19186bb6830aff69379c48"}, +] typing-extensions = [ {file = "typing_extensions-4.4.0-py3-none-any.whl", hash = "sha256:16fa4864408f655d35ec496218b85f79b3437c829e93320c7c9215ccfd92489e"}, {file = "typing_extensions-4.4.0.tar.gz", hash = "sha256:1511434bb92bf8dd198c12b1cc812e800d4181cfcb867674e0f8279cc93087aa"}, diff --git a/pyproject.toml b/pyproject.toml index 9955b4b..975dfce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,12 @@ license = "UNLICENSED" readme = "README.md" packages = [{include = "nqm", from = "src"}] + +[tool.poetry.build] +# build a custom C++ extension using build.py +script = "build.py" +generate-setup-file = true + [tool.poetry.dependencies] python = "^3.8" @@ -21,9 +27,10 @@ pytest = "^7.2.1" sphinx = "^5.3.0" myst-parser = "^0.18.1" isort = "^5.12.0" +types-setuptools = ">=45.2.0" [build-system] -requires = ["poetry-core"] +requires = ["poetry-core", "setuptools>=45.2.0"] build-backend = "poetry.core.masonry.api" [tool.pylint.main] diff --git a/src/nqm/_irimager/__init__.py b/src/nqm/_irimager/__init__.py new file mode 100644 index 0000000..84dd4ca --- /dev/null +++ b/src/nqm/_irimager/__init__.py @@ -0,0 +1 @@ +# This is a dummy package just to make Poetry happy diff --git a/src/nqm/irimager/__init__.py b/src/nqm/irimager/__init__.py deleted file mode 100644 index d557202..0000000 --- a/src/nqm/irimager/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# this file is purposely left blank to tell Python this is package diff --git a/src/nqm/irimager/__init__.pyi b/src/nqm/irimager/__init__.pyi new file mode 100644 index 0000000..710d1dd --- /dev/null +++ b/src/nqm/irimager/__init__.pyi @@ -0,0 +1,16 @@ +# This file was auto-generated and modified by hand from +# `poetry run stubgen -m nqm.irimager` + +"""Optris PI and XI imager IR camera controller + +We use the IRImagerDirect SDK +(see http://documentation.evocortex.com/libirimager2/html/index.html) +to control these cameras. +""" + +class IRImager: + """IRImager object - interfaces with a camera.""" + + def __init__(self, *args, **kwargs) -> None: ... + def test(self) -> int: + """Return the number 42""" diff --git a/src/nqm/irimager/irimager.cpp b/src/nqm/irimager/irimager.cpp new file mode 100644 index 0000000..68e1337 --- /dev/null +++ b/src/nqm/irimager/irimager.cpp @@ -0,0 +1,146 @@ +// contains example code adapted from https://opensource.com/article/22/11/extend-c-python + +#include +#include "structmember.h" + +#include + +// A struct contains the definition of a module +PyModuleDef irimager = { + PyModuleDef_HEAD_INIT, + "irimager", // Module name + R"(Optris PI and XI imager IR camera controller + +We use the IRImagerDirect SDK +(see http://documentation.evocortex.com/libirimager2/html/index.html) +to control these cameras.)", // docstring + -1, // Optional size of the module state memory + nullptr, // Optional module methods + nullptr, // Optional slot definitions + nullptr, // Optional traversal function + nullptr, // Optional clear function + nullptr // Optional module deallocation function +}; + +class IRImager { + public: + int test() { + return 42; + } +}; + +// Python object that stores an IRImager +typedef struct { + PyObject_HEAD + IRImager* m_myclass; +} IRImagerObject; + +PyObject* IRImager_test(PyObject *self, PyObject *args){ + assert(self); + + IRImagerObject* _self = reinterpret_cast(self); + int val = _self->m_myclass->test(); + return PyLong_FromLong(val); +} + +static PyMethodDef IRImager_methods[] = { + {"test", (PyCFunction)IRImager_test, METH_NOARGS, PyDoc_STR("Return the number 42")}, + {0, nullptr} /* Sentinel */ +}; + +static PyMemberDef IRImager_members[] = { + // currently no members + {nullptr, 0, 0, 0, nullptr} /* Sentinel */ +}; + +PyObject *IRImager_new(PyTypeObject *type, PyObject *args, PyObject *kwds){ + std::cout << "IRImager_new() called!" << std::endl; + + IRImagerObject *self = (IRImagerObject*) type->tp_alloc(type, 0); + if(self != nullptr){ // -> allocation successfull + // assign initial values + self->m_myclass = nullptr; + } + return (PyObject*) self; +} + +int IRImager_init(PyObject *self, PyObject *args, PyObject *kwds){ + IRImagerObject* m = (IRImagerObject*)self; + m->m_myclass = (IRImager*)PyObject_Malloc(sizeof(IRImager)); + + if(!m->m_myclass){ + PyErr_SetString(PyExc_RuntimeError, "Memory allocation failed"); + return -1; + } + + try { + new (m->m_myclass) IRImager(); + } catch (const std::exception& ex) { + PyObject_Free(m->m_myclass); + m->m_myclass = nullptr; + PyErr_SetString(PyExc_RuntimeError, ex.what()); + return -1; + } catch(...) { + PyObject_Free(m->m_myclass); + m->m_myclass = nullptr; + PyErr_SetString(PyExc_RuntimeError, "Initialization failed"); + return -1; + } + + return 0; +} + +void IRImager_dealloc(IRImagerObject *self){ + std::cout << "IRImager_dealloc() called!" << std::endl; + PyTypeObject *tp = Py_TYPE(self); + + IRImagerObject* m = reinterpret_cast(self); + + if(m->m_myclass){ + m->m_myclass->~IRImager(); + PyObject_Free(m->m_myclass); + } + + tp->tp_free(self); + Py_DECREF(tp); +}; + +PyDoc_STRVAR(IRImager_doc, R"(IRImager object - interfaces with a camera.)"); + +static PyType_Slot IRImager_slots[] = { + {Py_tp_new, (void*)IRImager_new}, + {Py_tp_init, (void*)IRImager_init}, + {Py_tp_dealloc, (void*)IRImager_dealloc}, + {Py_tp_members, IRImager_members}, + {Py_tp_methods, IRImager_methods}, + {Py_tp_doc, (void*)IRImager_doc}, + {0, nullptr} /* Sentinel */ +}; + +static PyType_Spec spec_IrImager = { + "nqm.irimager.IRImager", // name + sizeof(IRImagerObject) + sizeof(IRImager), // basicsize + 0, // itemsize + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // flags + IRImager_slots // slots +}; + +PyMODINIT_FUNC +PyInit_irimager(void) { + PyObject* module = PyModule_Create(&irimager); + + PyObject *irimager_class = PyType_FromSpec(&spec_IrImager); + if (irimager_class == NULL){ + std::cerr << "Failed to make irimager_class!" << std::endl; + return nullptr; + } + Py_INCREF(irimager_class); + + if(PyModule_AddObject(module, "IRImager", irimager_class) < 0){ + std::cerr << "Failed to add IRImager!" << std::endl; + Py_DECREF(irimager_class); + Py_DECREF(module); + return nullptr; + } + return module; +} diff --git a/src/nqm/irimager/py.typed b/src/nqm/irimager/py.typed new file mode 100644 index 0000000..149f653 --- /dev/null +++ b/src/nqm/irimager/py.typed @@ -0,0 +1 @@ +# This file is left blank and is needed for PEP 561 (typing info) diff --git a/tests/test_init.py b/tests/test_init.py deleted file mode 100644 index d2c685b..0000000 --- a/tests/test_init.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Tests for nqm.irimager""" - -def test_import(): - """Tests nqm.iam3dpo.cli._version""" - import nqm.irimager \ No newline at end of file diff --git a/tests/test_irimager.py b/tests/test_irimager.py new file mode 100644 index 0000000..814ff9b --- /dev/null +++ b/tests/test_irimager.py @@ -0,0 +1,9 @@ +"""Tests for nqm.irimager.IRImager""" +from nqm.irimager import IRImager + + +def test_irimager_test(): + """Tests nqm.irimager.IRImager""" + irimager = IRImager() + + assert irimager.test() == 42