diff --git a/.flake8 b/.flake8 index 2bcd70e3..816b90a4 100644 --- a/.flake8 +++ b/.flake8 @@ -1,2 +1,4 @@ [flake8] max-line-length = 88 +ignore = F401, W503 +# flake8 cannot be configured through pyproject.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 015fa129..4a6fe2f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,24 +21,17 @@ jobs: steps: - name: Checkout repository and submodules - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install package (with dependencies) run: | python -m pip install --upgrade pip - python -m pip install flake8 pytest==7.4.4 coverage coveralls pytest-cov - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - - name: Build adslib - run: | - python setup.py build - - name: Install package - run: | - python setup.py develop + pip install .[tests,dev] - name: Test with pytest run: | pytest -v --cov pyads diff --git a/.github/workflows/packaging.yml b/.github/workflows/packaging.yml new file mode 100644 index 00000000..f17e876d --- /dev/null +++ b/.github/workflows/packaging.yml @@ -0,0 +1,95 @@ +# +# Test job to see how packaging looks like. +# + +name: Build distributions + +on: [push, pull_request] + +jobs: + build_wheels: + name: Build wheels on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + with: + submodules: "true" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: | + python -m pip install -U pip + pip install build + + - run: python -m build --wheel -vv + - if: matrix.os == 'ubuntu-latest' + run: python -m build --sdist -vv + # We only need a single source distribution + + - uses: actions/upload-artifact@v4 + with: + name: dist-${{ matrix.os }} + retention-days: 1 + path: dist + + make_artifact: + name: Combine artifacts + needs: build_wheels + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + # Download all artifacts so far + - uses: actions/upload-artifact@v4 + with: + name: package-all + path: dist + + test_artifacts: + name: Test distributions + needs: make_artifact + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, macos-latest ] + # Can't really test with Windows because 'TcAdsDll.dll' will be missing + + steps: + - uses: actions/download-artifact@v4 + with: + name: package-all + path: dist + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # Now install the package from the local wheels and try to use it: + - run: | + pip install pyads --no-index --find-links ./dist + python -c "import pyads; pyads.Connection(ams_net_id='127.0.0.1.1.1', ams_net_port=851)" + + test_editable: + name: Test editable install + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: "true" + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: | + pip install -e . -vv + python -c "import pyads; pyads.Connection(ams_net_id='127.0.0.1.1.1', ams_net_port=851)" diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 56d2150b..0d450643 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -15,21 +15,21 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: submodules: recursive - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install setuptools wheel twine + pip install build twine - name: Build and publish env: TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} run: | - python setup.py sdist + python -m build twine upload dist/* diff --git a/.gitignore b/.gitignore index 7225462c..74be4ba3 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ build/ *.egg-info/ .eggs/ venv/ +.venv/ +venv_*/ *.tox deploy.py diff --git a/MANIFEST.in b/MANIFEST.in index 90891779..2a85b7c4 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,9 @@ -include adslib/* -include LICENSE -include README.rst +# +# Recipe for extra files to pack for setuptools. +# MANIFEST.in is a little outdated but the best solution to differentiate files between sdist and wheel builds. +# + +graft adslib +global-exclude *.a *.o *.obj *.bin *.so +prune obj +prune tests diff --git a/PKG-INFO b/PKG-INFO deleted file mode 100644 index 19b85975..00000000 --- a/PKG-INFO +++ /dev/null @@ -1,12 +0,0 @@ -Metadata-Version: 1.1 -Name: adsPy -Version: 1.0.2 -Summary: Python wrapper for TwinCAT ADS-DLL -Home-page: http://mrleeh.square7.ch/ -Author: Stefan Lehmann -Author-email: mrleeh@gmx.de -License: UNKNOWN -Description: UNKNOWN -Platform: UNKNOWN -Requires: ctypes -Provides: adsPy diff --git a/pyproject.toml b/pyproject.toml index 0da0448f..c1d393fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,16 +1,96 @@ +[project] +name = "pyads" +description = "Python wrapper for TwinCAT ADS library" +authors = [ + { name = "Stefan Lehmann", email = "Stefan.St.Lehmann@gmail.com" }, +] +readme = "README.md" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Topic :: Software Development :: Libraries", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", +] +license = { text = "MIT" } +dynamic = ["version"] +dependencies = [] + +[project.optional-dependencies] +docs = [ + "sphinx", + "sphinx_rtd_theme", + "recommonmark", +] +tests = [ + "pytest", + "pytest-cov", + "tox", +] +dev = [ + "build", + "flake8", + "pytest==7.4.4", + "coverage", + "coveralls", +] + +[project.urls] +Repository = "https://github.com/stlehmann/pyads" +Documentation = "https://pyads.readthedocs.io" + +[build-system] +requires = ["setuptools >= 61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["pyads", "pyads.testserver"] +package-dir = { "" = "src" } +include-package-data = true +# ^ needed for MANIFEST.in + +#[tool.setuptools.package-data] +# Package data (adslib/) is handled by MANIFEST.in instead + +[tool.setuptools.exclude-package-data] +pyads = ["adslib/**"] +# ^ Odd trick, put this excludes the adslib source again from a wheel build and from a pip install + +[tool.setuptools.dynamic] +version = {attr = "pyads.__version__"} + +[tool.tox] +legacy_tox_ini = """ + [tox] + envlist = py37, py38, py39, py310 + + [testenv] + commands = discover + deps = discover + changedir = tests + whitelist_externals=* + passenv = TWINCAT3DIR + + [pytest] + testpaths = tests +""" + [tool.black] line-length = 88 -target-version = ['py37', 'py38', 'py39', 'py310'] -include = '\.pyi?$' -exclude = ''' - +target-version = ["py37", "py38", "py39", "py310"] +include = ".pyi?$" +exclude = """ ( /( - \.eggs - | \.git - | \.mypy_cache - | \.tox - | \.venv + .eggs + | .git + | .mypy_cache + | .tox + | .venv | _build | buck-out | build @@ -20,4 +100,11 @@ exclude = ''' )/ | pyads/__init__.py ) -''' \ No newline at end of file +""" + +[tool.pydocstyle] +ignore = ["D105", "D213", "D203", "D107"] + +[tool.coverage.run] +include = ["pyads/*"] +omit = ["pyads/testserver/__main__.py"] diff --git a/requirements.in b/requirements.in deleted file mode 100644 index c0a48431..00000000 --- a/requirements.in +++ /dev/null @@ -1,4 +0,0 @@ -recommonmark -sphinx -sphinx_rtd_theme -tox diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index d4d3fc22..00000000 --- a/requirements.txt +++ /dev/null @@ -1,97 +0,0 @@ -# -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: -# -# pip-compile -# -alabaster==0.7.12 - # via sphinx -babel==2.9.1 - # via sphinx -backports.entry-points-selectable==1.1.0 - # via virtualenv -certifi==2021.10.8 - # via requests -charset-normalizer==2.0.7 - # via requests -colorama==0.4.4 - # via - # sphinx - # tox -commonmark==0.9.1 - # via recommonmark -distlib==0.3.3 - # via virtualenv -docutils==0.17.1 - # via - # recommonmark - # sphinx - # sphinx-rtd-theme -filelock==3.3.1 - # via - # tox - # virtualenv -idna==3.3 - # via requests -imagesize==1.2.0 - # via sphinx -jinja2==3.0.2 - # via sphinx -markupsafe==2.0.1 - # via jinja2 -packaging==21.0 - # via - # sphinx - # tox -platformdirs==2.4.0 - # via virtualenv -pluggy==1.0.0 - # via tox -py==1.10.0 - # via tox -pygments==2.10.0 - # via sphinx -pyparsing==2.4.7 - # via packaging -pytz==2021.3 - # via babel -recommonmark==0.7.1 - # via -r requirements.in -requests==2.26.0 - # via sphinx -six==1.16.0 - # via - # tox - # virtualenv -snowballstemmer==2.1.0 - # via sphinx -sphinx==4.2.0 - # via - # -r requirements.in - # recommonmark - # sphinx-rtd-theme -sphinx-rtd-theme==1.0.0 - # via -r requirements.in -sphinxcontrib-applehelp==1.0.2 - # via sphinx -sphinxcontrib-devhelp==1.0.2 - # via sphinx -sphinxcontrib-htmlhelp==2.0.0 - # via sphinx -sphinxcontrib-jsmath==1.0.1 - # via sphinx -sphinxcontrib-qthelp==1.0.3 - # via sphinx -sphinxcontrib-serializinghtml==1.1.5 - # via sphinx -toml==0.10.2 - # via tox -tox==3.24.4 - # via -r requirements.in -urllib3==1.26.7 - # via requests -virtualenv==20.8.1 - # via tox - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 929d9e1e..00000000 --- a/setup.cfg +++ /dev/null @@ -1,15 +0,0 @@ - -# See the docstring in versioneer.py for instructions. Note that you must -# re-run 'versioneer.py setup' after changing this section, and commit the -# resulting files. - -[coverage:run] -include = pyads/* -omit = - pyads/testserver/__main__.py - -[flake8] -ignore = F401, W503 - -[pydocstyle] -ignore = D105, D213, D203, D107 diff --git a/setup.py b/setup.py index e69d6f87..cf90ba34 100644 --- a/setup.py +++ b/setup.py @@ -1,182 +1,120 @@ -#! /usr/bin/env python -# -*-coding: utf-8 -*- -import io -import glob -import os -import re +from pathlib import Path +from setuptools import setup +from setuptools.command.install import install +from setuptools.command.build_py import build_py +from wheel.bdist_wheel import bdist_wheel import sys -import shutil +import sysconfig +import os import subprocess -import functools -import operator -from setuptools import setup -from setuptools.command.test import test as TestCommand -from setuptools.command.install import install as _install -from distutils.command.build import build as _build -from distutils.command.clean import clean as _clean -from distutils.command.sdist import sdist as _sdist - - -def read(*names, **kwargs): - try: - with io.open( - os.path.join(os.path.dirname(__file__), *names), - encoding=kwargs.get("encoding", "utf8") - ) as fp: - return fp.read() - except IOError: - return '' - - -def find_version(*file_paths): - version_file = read(*file_paths) - version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", - version_file, re.M) - if version_match: - return version_match.group(1) - raise RuntimeError("Unable to find version string.") - - -def platform_is_linux(): - return sys.platform.startswith('linux') or \ - sys.platform.startswith('darwin') -def get_files_rec(directory): - res = [] - for (path, directory, filenames) in os.walk(directory): - files = [os.path.join(path, fn) for fn in filenames] - res.append((path, files)) - return res +src_folder = Path(__file__).parent.absolute() / "src" +# ^ This will be on PATH for editable install +adslib_folder = Path(__file__).parent.absolute() / "adslib" +adslib_file = src_folder / "adslib.so" -data_files = get_files_rec('adslib') +class CustomBuildPy(build_py): + """Custom command for `build_py`. + This command class is used because it is always run, also for an editable install. + """ -def create_binaries(): - subprocess.call(['make', '-C', 'adslib']) + @classmethod + def compile_adslib(cls) -> bool: + """Return `True` if adslib was actually compiled.""" + if cls.platform_is_unix(): + cls._clean_library() + cls._compile_library() + return True + return False -def remove_binaries(): - """Remove all binary files in the adslib directory.""" - patterns = ( - "adslib/*.a", - "adslib/*.o", - "adslib/obj/*.o", - "adslib/*.bin", - "adslib/*.so", - ) + @staticmethod + def _compile_library(): + """Use `make` to build adslib - build is done in-place.""" + # Produce `adslib.so`: + subprocess.call(["make", "-C", "adslib"]) - for f in functools.reduce(operator.iconcat, [glob.glob(p) for p in patterns]): - os.remove(f) + @staticmethod + def _clean_library(): + """Remove all compilation artifacts.""" + patterns = ( + "*.a", + "**/*.o", + "*.bin", + "*.so", + ) + for pattern in patterns: + for file in adslib_folder.glob(pattern): + os.remove(file) + if adslib_file.is_file(): + os.remove(adslib_file) -def copy_sharedlib(): - try: - shutil.copy('adslib/adslib.so', 'pyads/adslib.so') - except OSError: - pass + @staticmethod + def platform_is_unix(): + return sys.platform.startswith("linux") or sys.platform.startswith("darwin") - -def remove_sharedlib(): - try: - os.remove('pyads/adslib.so') - except OSError: - pass - - -class build(_build): - def run(self): - if platform_is_linux(): - remove_binaries() - create_binaries() - copy_sharedlib() - remove_binaries() - _build.run(self) - - -class clean(_clean): def run(self): - if platform_is_linux(): - remove_binaries() - remove_sharedlib() - _clean.run(self) - + if self.compile_adslib(): + # Move .so file from Git submodule into src/ to have it on PATH: + self.move_file( + str(adslib_folder / "adslib.so"), + str(adslib_file), + ) -class sdist(_sdist): - def run(self): - if platform_is_linux(): - remove_binaries() - _sdist.run(self) + super().run() -class install(_install): +class CustomInstall(install): + """Install compiled adslib (but only for Linux).""" def run(self): - if platform_is_linux(): - create_binaries() - copy_sharedlib() - _install.run(self) + if CustomBuildPy.platform_is_unix(): + adslib_dest = Path(self.install_lib) + if not adslib_dest.exists(): + adslib_dest.mkdir(parents=True) + self.copy_file( + str(adslib_file), + str(adslib_dest), + ) + super().run() -class PyTest(TestCommand): - user_options = [('pytest-args=', 'a', "Arguments to pass to py.test")] +class CustomBDistWheel(bdist_wheel): + """Manually mark our wheel for a specific platform.""" - def initialize_options(self): - TestCommand.initialize_options(self) - self.pytest_args = ['--cov-report', 'html', '--cov-report', 'term', - '--cov=pyads'] + def get_tag(self): + """ - def finalize_options(self): - TestCommand.finalize_options(self) - self.test_args = [] - self.test_suite = True + See https://packaging.python.org/en/latest/specifications/platform-compatibility-tags/ + """ + impl_tag = "py2.py3" # Same wheel across Python versions + abi_tag = "none" # Same wheeel across ABI versions (not a C-extension) + # But we need to differentiate on the platform for the compiled adslib: + plat_tag = sysconfig.get_platform().replace("-", "_").replace(".", "_") - def run_tests(self): - import pytest - errno = pytest.main(self.pytest_args) - sys.exit(errno) + if plat_tag.startswith("linux_"): + # But the basic Linux prefix is deprecated, use new scheme instead: + plat_tag = "manylinux_2_24" + plat_tag[5:] + # MacOS platform tags area already okay -cmdclass = { - 'test': PyTest, - 'build': build, - 'clean': clean, - 'sdist': sdist, - 'install': install, -} + # We also keep Windows tags in place, instead of using `any`, to prevent an + # obscure Linux platform to getting a wheel without adslib source - -long_description = read('README.md') + return impl_tag, abi_tag, plat_tag +# noinspection PyTypeChecker setup( - name="pyads", - version=find_version('pyads', '__init__.py'), - description="Python wrapper for TwinCAT ADS library", - long_description=long_description, - long_description_content_type='text/markdown', - author="Stefan Lehmann", - author_email="Stefan.St.Lehmann@gmail.com", - packages=["pyads", "pyads.testserver"], - package_data={'pyads': ['adslib.so']}, - requires=[], - install_requires=[], - provides=['pyads'], - url='https://github.com/MrLeeh/pyads', - classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: MIT License', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Topic :: Software Development :: Libraries', - 'Operating System :: Microsoft :: Windows', - 'Operating System :: Microsoft :: Windows :: Windows 7', - 'Operating System :: POSIX :: Linux', - ], - cmdclass=cmdclass, - data_files=data_files, - tests_require=['pytest', 'pytest-cov'], - has_ext_modules=lambda: True, + cmdclass={ + "build_py": CustomBuildPy, + "install": CustomInstall, + "bdist_wheel": CustomBDistWheel, + }, ) +# See `pyproject.toml` for all package information + +# Also see `MANIFEST.in` diff --git a/pyads/__init__.py b/src/pyads/__init__.py similarity index 100% rename from pyads/__init__.py rename to src/pyads/__init__.py diff --git a/pyads/ads.py b/src/pyads/ads.py similarity index 100% rename from pyads/ads.py rename to src/pyads/ads.py diff --git a/pyads/connection.py b/src/pyads/connection.py similarity index 100% rename from pyads/connection.py rename to src/pyads/connection.py diff --git a/pyads/constants.py b/src/pyads/constants.py similarity index 100% rename from pyads/constants.py rename to src/pyads/constants.py diff --git a/pyads/errorcodes.py b/src/pyads/errorcodes.py similarity index 100% rename from pyads/errorcodes.py rename to src/pyads/errorcodes.py diff --git a/pyads/filetimes.py b/src/pyads/filetimes.py similarity index 100% rename from pyads/filetimes.py rename to src/pyads/filetimes.py diff --git a/pyads/pyads_ex.py b/src/pyads/pyads_ex.py similarity index 99% rename from pyads/pyads_ex.py rename to src/pyads/pyads_ex.py index 6a7e4459..139c665c 100644 --- a/pyads/pyads_ex.py +++ b/src/pyads/pyads_ex.py @@ -81,14 +81,19 @@ ) elif platform_is_linux(): - # try to load local adslib.so in favor to global one - local_adslib = os.path.join(os.path.dirname(__file__), "adslib.so") - if os.path.isfile(local_adslib): - adslib = local_adslib - else: - adslib = "adslib.so" + adslib_path = None - _adsDLL = ctypes.CDLL(adslib) + for p in sys.path: + adslib_path = os.path.join(p, "adslib.so") + if os.path.exists(adslib_path): + break + + if adslib_path is None: + raise OSError(f"Failed to locate `adslib.so` library in {sys.path}") + + # For some reason loading on just "adslib.so" always fails, even if it is under + # sys.path, so manually search for it first + _adsDLL = ctypes.CDLL(adslib_path) NOTEFUNC = ctypes.CFUNCTYPE( None, diff --git a/pyads/structs.py b/src/pyads/structs.py similarity index 100% rename from pyads/structs.py rename to src/pyads/structs.py diff --git a/pyads/symbol.py b/src/pyads/symbol.py similarity index 100% rename from pyads/symbol.py rename to src/pyads/symbol.py diff --git a/pyads/testserver/__init__.py b/src/pyads/testserver/__init__.py similarity index 100% rename from pyads/testserver/__init__.py rename to src/pyads/testserver/__init__.py diff --git a/pyads/testserver/__main__.py b/src/pyads/testserver/__main__.py similarity index 100% rename from pyads/testserver/__main__.py rename to src/pyads/testserver/__main__.py diff --git a/pyads/testserver/advanced_handler.py b/src/pyads/testserver/advanced_handler.py similarity index 100% rename from pyads/testserver/advanced_handler.py rename to src/pyads/testserver/advanced_handler.py diff --git a/pyads/testserver/basic_handler.py b/src/pyads/testserver/basic_handler.py similarity index 100% rename from pyads/testserver/basic_handler.py rename to src/pyads/testserver/basic_handler.py diff --git a/pyads/testserver/handler.py b/src/pyads/testserver/handler.py similarity index 100% rename from pyads/testserver/handler.py rename to src/pyads/testserver/handler.py diff --git a/pyads/testserver/testserver.py b/src/pyads/testserver/testserver.py similarity index 100% rename from pyads/testserver/testserver.py rename to src/pyads/testserver/testserver.py diff --git a/pyads/utils.py b/src/pyads/utils.py similarity index 100% rename from pyads/utils.py rename to src/pyads/utils.py diff --git a/tox.ini b/tox.ini index aa05b256..e69de29b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +0,0 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = py37, py38, py39, py310 - -[testenv] -commands = discover -deps = discover -changedir = tests -whitelist_externals=* -passenv = TWINCAT3DIR - -[pytest] -testpaths = tests