diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2ae86f1..46219f9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,6 +22,7 @@ jobs: libgpgme-dev libgtest-dev make + python3-pytest valgrind env: DEBIAN_FRONTEND: noninteractive diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000..61b5124 --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,35 @@ +--- +name: Python Packaging + +on: # yamllint disable-line rule:truthy + push: + tags: ['*'] + workflow_dispatch: + +jobs: + sdist: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + - run: python -m pip install -U build + - run: python -m build --sdist + - uses: actions/upload-artifact@v4 + with: + name: sdist + path: dist/*.tar.gz + wheel: + strategy: + matrix: + include: + - os: ubuntu-24.04 + arch: x86_64 + - os: ubuntu-24.04-arm + arch: aarch64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v5 + - uses: pypa/cibuildwheel@v3.2.0 + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.arch }} + path: ./wheelhouse/*.whl diff --git a/.gitignore b/.gitignore index 50078ee..2d32ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ -/build +/build/ +/dist/ +/wheelhouse/ .*.swp diff --git a/CMakeLists.txt b/CMakeLists.txt index 466a3f8..43a63b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.10) +cmake_minimum_required(VERSION 3.15) project(createrepo-agent C) @@ -35,6 +35,7 @@ target_link_libraries(createrepo-cache PUBLIC target_compile_definitions(createrepo-cache PRIVATE -DG_LOG_DOMAIN="CREATEREPO_CACHE") set_target_properties(createrepo-cache PROPERTIES + POSITION_INDEPENDENT_CODE ON SOVERSION ${CRA_VERSION_MAJOR} VERSION ${CRA_VERSION}) @@ -60,6 +61,8 @@ target_link_libraries(createrepo-agent-lib PUBLIC glib-2.0) target_compile_definitions(createrepo-agent-lib PRIVATE -DG_LOG_DOMAIN="CREATEREPO_AGENT") +set_target_properties(createrepo-agent-lib PROPERTIES + POSITION_INDEPENDENT_CODE ON) # Executable add_executable(${PROJECT_NAME} @@ -75,6 +78,73 @@ install(TARGETS ${PROJECT_NAME} RUNTIME DESTINATION bin COMPONENT bin) +# Python bindings +cmake_policy(SET CMP0094 NEW) +set(Python_FIND_UNVERSIONED_NAMES FIRST + CACHE STRING "Defines how the generic names will be searched for Python") +if(CMAKE_VERSION VERSION_LESS "3.18") + set(Python_COMPONENTS Interpreter Development) +else() + set(Python_COMPONENTS Interpreter Development.Module) +endif() +find_package(Python 3 QUIET COMPONENTS ${Python_COMPONENTS}) +option(WITH_PYTHON "Build Python bindings" ${Python_FOUND}) +if(WITH_PYTHON) + # Find Python again, this time REQUIRED + find_package(Python 3 REQUIRED COMPONENTS ${Python_COMPONENTS}) + + python_add_library(createrepo_agent MODULE + src/python/client.c + src/python/init.c + src/python/server.c) + target_link_libraries(createrepo_agent PRIVATE + createrepo-agent-lib) + + if(SKBUILD) + set(PYTHON_INSTALL_DIR ${SKBUILD_PLATLIB_DIR}) + else() + # Determine package installation location + string(JOIN "; " PYTHON_INSTALL_DIR_CMD + "from os.path import sep" + "from sys import stdout" + "from sysconfig import get_path" + "stdout.write(get_path('platlib', vars={'base': '', 'platbase': ''}).lstrip(sep))" + ) + execute_process(COMMAND ${Python_EXECUTABLE} -c "${PYTHON_INSTALL_DIR_CMD}" + OUTPUT_VARIABLE PYTHON_INSTALL_DIR) + + set(DISTINFO_NAME "createrepo_agent-${CRA_VERSION}.dist-info") + set(DISTINFO_DIR "${CMAKE_CURRENT_BINARY_DIR}/${DISTINFO_NAME}") + file(GENERATE + OUTPUT "${DISTINFO_DIR}/INSTALLER" + CONTENT "cmake\n") + file(GENERATE + OUTPUT "${DISTINFO_DIR}/METADATA" + CONTENT "Metadata-Version: 1.1\nName: createrepo-agent\nVersion: ${CRA_VERSION}\n") + string(JOIN "\n" RECORD_CONTENT + "$,," + "${DISTINFO_NAME}/INSTALLER,," + "${DISTINFO_NAME}/METADATA,," + "${DISTINFO_NAME}/RECORD,," + "${DISTINFO_NAME}/top_level.txt,," + ) + file(GENERATE + OUTPUT "${DISTINFO_DIR}/RECORD" + CONTENT "${RECORD_CONTENT}\n") + file(GENERATE + OUTPUT "${DISTINFO_DIR}/top_level.txt" + CONTENT "createrepo_agent\n") + install(DIRECTORY ${DISTINFO_DIR} + DESTINATION ${PYTHON_INSTALL_DIR} + COMPONENT python) + endif() + + install(TARGETS createrepo_agent + RUNTIME DESTINATION ${PYTHON_INSTALL_DIR} + LIBRARY DESTINATION ${PYTHON_INSTALL_DIR} + COMPONENT python) +endif() + add_subdirectory(doc) set(MEMORYCHECK_SUPPRESSIONS_FILE "${CMAKE_CURRENT_SOURCE_DIR}/test/valgrind.supp" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..37e06b0 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,46 @@ +[build-system] +requires = ["scikit-build-core"] +build-backend = "scikit_build_core.build" + +[project] +name = "createrepo-agent" +dynamic = ["version"] + +[project.optional-dependencies] +test = [ + "pytest", +] + +[tool.scikit-build] +install.components = ["python"] +sdist.exclude = [ + ".editorconfig", + ".github", + ".gitignore", + "CONTRIBUTING.md", +] +wheel.packages = [] + +[tool.scikit-build.cmake.define] +BUILD_TESTING = false +WITH_PYTHON = true + +[tool.scikit-build.metadata.version] +provider = "scikit_build_core.metadata.regex" +input = "CMakeLists.txt" +regex = ''' +set\(CRA_VERSION_MAJOR\s+(?P\d+)\) +set\(CRA_VERSION_MINOR\s+(?P\d+)\) +set\(CRA_VERSION_PATCH\s+(?P\d+)\) +''' +result = "{major}.{minor}.{patch}" + +[tool.cibuildwheel] +skip = "*-musllinux_*" +test-extras = ["test"] +test-command = [ + "python -m pytest {project}/test", +] + +[tool.cibuildwheel.linux] +before-all = "dnf install -y createrepo_c-devel glib2-devel gpgme-devel libassuan-devel libgpg-error-devel" diff --git a/src/python/client.c b/src/python/client.c new file mode 100644 index 0000000..e5b2662 --- /dev/null +++ b/src/python/client.c @@ -0,0 +1,322 @@ +// Copyright 2025 Open Source Robotics Foundation, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include + +#include "python/client.h" + +typedef struct +{ + PyObject_HEAD + gchar * name; + assuan_context_t ctx; +} ClientObject; + +static PyObject * +client_disconnect(ClientObject *self, PyObject *args); + +static PyObject * +client_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + (void)args; + (void)kwds; + + ClientObject *self = (ClientObject *)type->tp_alloc(type, 0); + if (self) { + self->ctx = NULL; + self->name = NULL; + } + return (PyObject *)self; +} + +static int +client_init(ClientObject *self, PyObject *args, PyObject *kwds) +{ + static char * keywords[] = { + "name", + NULL, + }; + + char *name = NULL; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", keywords, &name)) { + return -1; + } + + if (NULL != self->name) { + g_free(self->name); + } + + self->name = g_strdup(name); + if (NULL == self->name) { + PyErr_NoMemory(); + return -1; + } + + return 0; +} +static void +client_dealloc(ClientObject *self) +{ + Py_XDECREF(client_disconnect(self, NULL)); + + if (NULL != self->name) { + g_free(self->name); + } + + Py_TYPE(self)->tp_free(self); +} + +static PyObject * +client_repr(ClientObject *self) +{ + return PyUnicode_FromFormat( + "", self->name); +} + +static PyObject * +execute_transaction(ClientObject *self, const char * cmd) +{ + gpg_error_t rc; + + rc = assuan_transact(self->ctx, cmd, NULL, NULL, NULL, NULL, NULL, NULL); + if (rc) { + PyErr_Format(PyExc_RuntimeError, "Transaction failed: %s", gpg_strerror(rc)); + return NULL; + } + + Py_RETURN_NONE; +} + +static const gchar ** +sequence_to_str_array(PyObject *sequence) +{ + Py_ssize_t len = PySequence_Length(sequence); + assert(len >= 0); + + const gchar **res = g_new0(const gchar *, (unsigned)len + 1); + if (NULL == res) { + PyErr_NoMemory(); + return NULL; + } + + for (Py_ssize_t i = 0; i < len; i++) { + PyObject *item = PySequence_Fast_GET_ITEM(sequence, i); + res[i] = PyUnicode_AsUTF8(item); + if (NULL == res[i]) { + PyErr_Format(PyExc_TypeError, "arches contains non-string at index %u", i); + g_free(res); + return NULL; + } + } + + return res; +} + +static PyObject * +client_add(ClientObject *self, PyObject *args) +{ + char *package = NULL; + PyObject *arches = NULL; + gchar *arch_list = NULL; + gchar *cmd; + PyObject *ret; + + if (!PyArg_ParseTuple(args, "s|O", &package, &arches)) { + return NULL; + } + + if (arches != NULL && arches != Py_None) { + if (1 != PySequence_Check(arches)) { + PyErr_SetString(PyExc_TypeError, "arches must be an iterable"); + return NULL; + } + + const gchar **arch_array = sequence_to_str_array(arches); + if (NULL == arch_array) { + return NULL; + } + + arch_list = g_strjoinv(" ", (gchar **)arch_array); + g_free(arch_array); + } + + cmd = g_strjoin(" ", "ADD", package, arch_list, NULL); + g_free(arch_list); + if (!cmd) { + return PyErr_NoMemory(); + } + + ret = execute_transaction(self, cmd); + g_free(cmd); + return ret; +} + +static PyObject * +client_commit(ClientObject *self, PyObject *args) +{ + (void)args; + + return execute_transaction(self, "COMMIT"); +} + +static PyObject * +client_connect(ClientObject *self, PyObject *args) +{ + (void)args; + + gpg_error_t rc; + + gchar *cwd = g_path_is_absolute(self->name) ? NULL : g_get_current_dir(); + gchar *sockpath = g_strconcat( + cwd ? cwd : "", + cwd && !g_str_has_suffix(cwd, "/") ? "/" : "", + self->name, + g_str_has_suffix(self->name, "/") ? "" : "/", + CRA_SOCK_NAME, + NULL); + g_free(cwd); + if (NULL == sockpath) { + return PyErr_NoMemory(); + } + + assuan_release(self->ctx); + rc = assuan_new(&self->ctx); + if (rc) { + PyErr_Format(PyExc_RuntimeError, "Failed to initialize Assuan context: %s", gpg_strerror(rc)); + g_free(sockpath); + return NULL; + } + + rc = assuan_socket_connect(self->ctx, sockpath, ASSUAN_INVALID_PID, 0); + g_free(sockpath); + if (rc) { + PyErr_Format(PyExc_RuntimeError, "Failed to connect to server: %s", gpg_strerror(rc)); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject * +client_disconnect(ClientObject *self, PyObject *args) +{ + (void)args; + + assuan_release(self->ctx); + self->ctx = NULL; + + Py_RETURN_NONE; +} + +static PyObject * +set_option(ClientObject *self, const char * option_name, int value) +{ + gchar * cmd; + PyObject *res; + + cmd = g_strjoin( + " ", + "OPTION", + option_name, + value ? "1" : "0", + NULL); + if (!cmd) { + return PyErr_NoMemory(); + } + + res = execute_transaction(self, cmd); + g_free(cmd); + return res; +} + +static PyObject * +client_set_invalidate_dependants(ClientObject *self, PyObject *args) +{ + int invalidate_dependants; + + if (!PyArg_ParseTuple(args, "p", &invalidate_dependants)) { + return NULL; + } + + return set_option(self, "invalidate_dependants", invalidate_dependants); +} + +static PyObject * +client_set_invalidate_family(ClientObject *self, PyObject *args) +{ + int invalidate_family; + + if (!PyArg_ParseTuple(args, "p", &invalidate_family)) { + return NULL; + } + + return set_option(self, "invalidate_family", invalidate_family); +} + +static PyObject * +client_enter(ClientObject *self, PyObject *args) +{ + (void)args; + + PyObject *res = client_connect(self, NULL); + if (NULL == res) { + return NULL; + } + Py_DECREF(res); + + Py_INCREF(self); + + return (PyObject *)self; +} + +static PyObject * +client_get_name(ClientObject *self, void *closure) +{ + (void)closure; + + return PyUnicode_FromString(self->name); +} + +static struct PyMethodDef client_methods[] = { + {"add", (PyCFunction)client_add, METH_VARARGS, NULL}, + {"commit", (PyCFunction)client_commit, METH_NOARGS, NULL}, + {"connect", (PyCFunction)client_connect, METH_NOARGS, NULL}, + {"disconnect", (PyCFunction)client_disconnect, METH_NOARGS, NULL}, + {"set_invalidate_dependants", (PyCFunction)client_set_invalidate_dependants, METH_VARARGS, NULL}, + {"set_invalidate_family", (PyCFunction)client_set_invalidate_family, METH_VARARGS, NULL}, + {"__enter__", (PyCFunction)client_enter, METH_NOARGS, NULL}, + {"__exit__", (PyCFunction)client_disconnect, METH_VARARGS, NULL}, + {NULL, NULL, 0, NULL} +}; + +static struct PyGetSetDef client_properties[] = { + {"name", (getter)client_get_name, NULL, NULL, NULL}, + {NULL, NULL, NULL, NULL, NULL} +}; + +PyTypeObject Client_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "createrepo_agent.Client", + .tp_basicsize = sizeof(ClientObject), + .tp_new = client_new, + .tp_dealloc = (destructor)client_dealloc, + .tp_init = (initproc)client_init, + .tp_repr = (reprfunc)client_repr, + .tp_methods = client_methods, + .tp_getset = client_properties, +}; diff --git a/src/python/client.h b/src/python/client.h new file mode 100644 index 0000000..9b4553d --- /dev/null +++ b/src/python/client.h @@ -0,0 +1,31 @@ +// Copyright 2025 Open Source Robotics Foundation, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PYTHON__CLIENT_H_ +#define PYTHON__CLIENT_H_ + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +extern PyTypeObject Client_Type; + +#ifdef __cplusplus +} +#endif + +#endif // PYTHON__CLIENT_H_ diff --git a/src/python/init.c b/src/python/init.c new file mode 100644 index 0000000..936e0c9 --- /dev/null +++ b/src/python/init.c @@ -0,0 +1,75 @@ +// Copyright 2025 Open Source Robotics Foundation, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include + +#include "createrepo-agent/common.h" +#include "python/client.h" +#include "python/server.h" + +void free_createrepo_agent(void *self) +{ + (void)self; + + assuan_sock_deinit(); +} + +static struct PyModuleDef createrepo_agent_module_def = { + PyModuleDef_HEAD_INIT, + "createrepo_agent", + NULL, + 0, + NULL, + NULL, + NULL, + NULL, + free_createrepo_agent, +}; + +PyObject * +PyInit_createrepo_agent(void) +{ + PyObject *m = PyModule_Create(&createrepo_agent_module_def); + if (!m) { + return NULL; + } + + gpgrt_check_version(NULL); + gpgme_check_version(NULL); + assuan_sock_init(); + + if (PyType_Ready(&Client_Type) < 0) { + return NULL; + } + Py_INCREF(&Client_Type); + PyModule_AddObject(m, "Client", (PyObject *)&Client_Type); + + if (PyType_Ready(&Server_Type) < 0) { + return NULL; + } + Py_INCREF(&Server_Type); + PyModule_AddObject(m, "Server", (PyObject *)&Server_Type); + + PyModule_AddStringConstant(m, "__version__", CRA_VERSION); + PyModule_AddStringConstant(m, "SOCK_NAME", CRA_SOCK_NAME); + + PyModule_AddIntConstant(m, "EXIT_SUCCESS", CRA_EXIT_SUCCESS); + PyModule_AddIntConstant(m, "EXIT_GENERAL_ERROR", CRA_EXIT_GENERAL_ERROR); + PyModule_AddIntConstant(m, "EXIT_USAGE", CRA_EXIT_USAGE); + PyModule_AddIntConstant(m, "EXIT_IN_USE", CRA_EXIT_IN_USE); + + return m; +} diff --git a/src/python/server.c b/src/python/server.c new file mode 100644 index 0000000..c3c48db --- /dev/null +++ b/src/python/server.c @@ -0,0 +1,228 @@ +// Copyright 2025 Open Source Robotics Foundation, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include +#include +#include +#include +#include +#include + +#include "python/server.h" + +typedef struct +{ + PyObject_HEAD + assuan_fd_t fd; + gchar * name; + volatile sig_atomic_t sentinel; + GThread * thread; +} ServerObject; + +static PyObject * +server_shutdown_thread(ServerObject *self, PyObject *args); + +static void * server_thread(ServerObject *self) +{ + command_handler(self->fd, self->name, &self->sentinel); + + return NULL; +} + +static PyObject * +server_new(PyTypeObject *type, PyObject *args, PyObject *kwds) +{ + (void)args; + (void)kwds; + + ServerObject *self = (ServerObject *)type->tp_alloc(type, 0); + if (self) { + self->fd = ASSUAN_INVALID_FD; + self->name = NULL; + self->sentinel = 0; + self->thread = NULL; + } + return (PyObject *)self; +} + +static int +server_init(ServerObject *self, PyObject *args, PyObject *kwds) +{ + static char * keywords[] = { + "name", + NULL, + }; + + char *name = NULL; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", keywords, &name)) { + return -1; + } + + if (NULL != self->name) { + g_free(self->name); + } + + self->name = g_strdup(name); + if (NULL == self->name) { + PyErr_NoMemory(); + return -1; + } + + return 0; +} + +static void +server_dealloc(ServerObject *self) +{ + Py_XDECREF(server_shutdown_thread(self, NULL)); + + if (NULL != self->name) { + g_free(self->name); + } + + Py_TYPE(self)->tp_free(self); +} + +static PyObject * +server_repr(ServerObject *self) +{ + return PyUnicode_FromFormat( + "", self->name); +} + +static PyObject * +server_shutdown_thread(ServerObject *self, PyObject *args) +{ + (void)args; + + self->sentinel = 1; + if (ASSUAN_INVALID_FD != self->fd) { + shutdown(self->fd, SHUT_RD); + self->fd = ASSUAN_INVALID_FD; + } + + if (NULL != self->thread) { + g_thread_join(self->thread); + self->thread = NULL; + } + + self->sentinel = 0; + + Py_RETURN_NONE; +} + +static PyObject * +server_start_thread(ServerObject *self, PyObject *args) +{ + (void)args; + + if (ASSUAN_INVALID_FD != self->fd) { + PyErr_SetString(PyExc_RuntimeError, "Server is already active"); + return NULL; + } + + gchar *cwd = g_path_is_absolute(self->name) ? NULL : g_get_current_dir(); + gchar *sockpath = g_strconcat( + cwd ? cwd : "", + cwd && !g_str_has_suffix(cwd, "/") ? "/" : "", + self->name, + g_str_has_suffix(self->name, "/") ? "" : "/", + CRA_SOCK_NAME, + NULL); + g_free(cwd); + if (NULL == sockpath) { + return PyErr_NoMemory(); + } + + self->fd = create_server_socket(sockpath); + if (self->fd == ASSUAN_INVALID_FD && errno == EADDRINUSE) { + gpg_error_t res = try_server(sockpath); + if (res) { + // TODO(cottsay): Better handling of redirected socket + remove(sockpath); + self->fd = create_server_socket(sockpath); + } else { + errno = EADDRINUSE; + } + } + if (ASSUAN_INVALID_FD == self->fd) { + PyErr_SetFromErrnoWithFilename(PyExc_OSError, sockpath); + g_free(sockpath); + return NULL; + } + g_free(sockpath); + + self->sentinel = 0; + self->thread = g_thread_new(NULL, (GThreadFunc) & server_thread, self); + if (!self->thread) { + assuan_sock_close(self->fd); + self->fd = ASSUAN_INVALID_FD; + PyErr_SetString(PyExc_RuntimeError, "Failed to start thread"); + return NULL; + } + + Py_RETURN_NONE; +} + +static PyObject * +server_enter(ServerObject *self, PyObject *args) +{ + (void)args; + + PyObject *res = server_start_thread(self, NULL); + if (NULL == res) { + return NULL; + } + Py_DECREF(res); + + Py_INCREF(self); + + return (PyObject *)self; +} + +static PyObject * +server_get_name(ServerObject *self, void *closure) +{ + (void)closure; + + return PyUnicode_FromString(self->name); +} + +static struct PyMethodDef server_methods[] = { + {"shutdown_thread", (PyCFunction)server_shutdown_thread, METH_NOARGS, NULL}, + {"start_thread", (PyCFunction)server_start_thread, METH_NOARGS, NULL}, + {"__enter__", (PyCFunction)server_enter, METH_NOARGS, NULL}, + {"__exit__", (PyCFunction)server_shutdown_thread, METH_VARARGS, NULL}, + {NULL, NULL, 0, NULL} +}; + +static struct PyGetSetDef server_properties[] = { + {"name", (getter)server_get_name, NULL, NULL, NULL}, + {NULL, NULL, NULL, NULL, NULL} +}; + +PyTypeObject Server_Type = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "createrepo_agent.Server", + .tp_basicsize = sizeof(ServerObject), + .tp_new = server_new, + .tp_dealloc = (destructor)server_dealloc, + .tp_init = (initproc)server_init, + .tp_repr = (reprfunc)server_repr, + .tp_methods = server_methods, + .tp_getset = server_properties, +}; diff --git a/src/python/server.h b/src/python/server.h new file mode 100644 index 0000000..baec5eb --- /dev/null +++ b/src/python/server.h @@ -0,0 +1,31 @@ +// Copyright 2025 Open Source Robotics Foundation, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#ifndef PYTHON__SERVER_H_ +#define PYTHON__SERVER_H_ + +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + +extern PyTypeObject Server_Type; + +#ifdef __cplusplus +} +#endif + +#endif // PYTHON__SERVER_H_ diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 64ca838..1493244 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -37,3 +37,12 @@ add_cache_test(repo_cache_invalidate) add_cache_test(repo_cache_modify) add_integration_test(smoke) + +if(WITH_PYTHON) + add_test( + NAME pytest + COMMAND ${CMAKE_COMMAND} -E env + "PYTHONPATH=$" + "PYTHONDONTWRITEBYTECODE=1" + ${Python_EXECUTABLE} -m pytest ${CMAKE_CURRENT_SOURCE_DIR}) +endif() diff --git a/test/test_smoke.py b/test/test_smoke.py new file mode 100644 index 0000000..68512a4 --- /dev/null +++ b/test/test_smoke.py @@ -0,0 +1,47 @@ +# Copyright 2025 Open Source Robotics Foundation, Inc. +# Licensed under the Apache License, Version 2.0 + +from pathlib import Path + +import createrepo_agent +import pytest + +FIXTURES_DIR = Path(__file__).parent / 'fixtures' + + +def test_version(): + assert createrepo_agent.__version__ + + +def test_add(tmp_path): + packages_path = FIXTURES_DIR / 'populated' / 'x86_64' / 'Packages' + rpm_path = packages_path / 'r' / 'ros-dev-tools-1.0.1-1.el9.noarch.rpm' + + with createrepo_agent.Server(str(tmp_path)): + with createrepo_agent.Client(str(tmp_path)) as c: + with pytest.raises(TypeError): + c.add(str(rpm_path), 1) + with pytest.raises(TypeError): + c.add(str(rpm_path), (1,)) + c.set_invalidate_dependants(True) + c.set_invalidate_family(True) + c.add(str(rpm_path), ('x86_64',)) + c.commit() + + assert (tmp_path / 'x86_64' / 'repodata' / 'repomd.xml').is_file() + + +def test_commit_nothing(tmp_path): + with createrepo_agent.Server(str(tmp_path)): + with createrepo_agent.Client(str(tmp_path)) as c: + c.commit() + + +def test_server_socket_collision(tmp_path): + with createrepo_agent.Server(str(tmp_path)): + pass + + with createrepo_agent.Server(str(tmp_path)): + with pytest.raises(OSError): + with createrepo_agent.Server(str(tmp_path)): + pass