From 55f2ed5182d9783bf17df5d7cf48ddf85ca7b144 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Wed, 17 Jan 2024 16:11:25 +0000 Subject: [PATCH 1/4] README: Remove references to distutils command Signed-off-by: Stephen Finucane --- README.md | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 31aa47d..42b0a53 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,16 @@ Create **man pages** for [click](https://github.com/pallets/click) application as easy as this: ```bash -python3 setup.py --command-packages=click_man.commands man_pages +click-man foo ``` +where `foo` is the name of your script, as defined in [`console_scripts`](https://python-packaging.readthedocs.io/en/latest/command-line-scripts.html#the-console-scripts-entry-point). + → Checkout the [debian packaging example](#debian-packages) ## What it does -*click-man* will generate one man page per command of your click CLI application specified in `console_scripts` in your `setup.py`. +*click-man* will generate one man page per command of your click CLI application specified in `console_scripts` in your `setup.py` / `setup.cfg` / `pyproject.toml`. ## Installation @@ -47,20 +49,6 @@ To specify a target directory for the man pages, use the `--target` option: click-man --target path/to/man/pages commandname ``` -### Use with setuptools - -**click-man** provides a sane setuptools command extension which can be used like the following: - -```bash -python3 setup.py --command-packages=click_man.commands man_pages -``` - -or specify the man pages target directory: - -```bash -python3 setup.py --command-packages=click_man.commands man_pages --target path/to/man/pages -``` - ### Automatic man page installation with setuptools and pip This approach of installing man pages is problematic for various reasons: @@ -68,8 +56,7 @@ This approach of installing man pages is problematic for various reasons: #### (1) Man pages are a UNIX thing Python in general and with that pip and setuptools are aimed to be platform independent. -Man pages are **not**: they are a UNIX thing which means setuptools does not provide a sane -solution to generate and install man pages. +Man pages are **not**: they are a UNIX thing which means setuptools does not provide a sane solution to generate and install man pages. We should consider using automatic man page installation only with vendor specific packaging, e.g. for `*.deb` or `*.rpm` packages. #### (2) Man pages are not compatible with Python virtualenvs @@ -88,8 +75,7 @@ the version and behavior available in any given virtualenv. #### (3) We want to generate man pages on the fly First, we do not want to commit man pages to our source control. -We want to generate them on the fly. Either -during build or installation time. +We want to generate them on the fly, either during build or installation time. With setuptools and pip we face two problems: @@ -109,7 +95,7 @@ We override the rule provided by `dh_installman` to generate our man pages in ad ```Makefile override_dh_installman: - python3 setup.py --command-packages=click_man.commands man_pages --target debian/tmp/manpages + click-man --target debian/tmp/manpages dh_installman -O--buildsystem=pybuild ``` From fa7d271f519d35bcfa892eeb73931e3d6476d9b1 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Wed, 17 Jan 2024 16:04:14 +0000 Subject: [PATCH 2/4] Remove distutils command distutils is gone in Python 3.12 [1]. We can no longer support this. [1] https://docs.python.org/3.11/library/distutils.html Signed-off-by: Stephen Finucane --- click_man/commands/__init__.py | 5 --- click_man/commands/man_pages.py | 67 --------------------------------- 2 files changed, 72 deletions(-) delete mode 100644 click_man/commands/__init__.py delete mode 100644 click_man/commands/man_pages.py diff --git a/click_man/commands/__init__.py b/click_man/commands/__init__.py deleted file mode 100644 index 0271ae3..0000000 --- a/click_man/commands/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from . import man_pages - -__all__ = [ - 'man_pages' -] diff --git a/click_man/commands/man_pages.py b/click_man/commands/man_pages.py deleted file mode 100644 index 0dc4824..0000000 --- a/click_man/commands/man_pages.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -click-man - Generate man pages for click application -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This module provides a setuptools command to -generate man pages from a click application. - -:copyright: (c) 2016 by Timo Furrer. -:license: MIT, see LICENSE for more details. -""" - -import os -from distutils.core import Command -from distutils.errors import DistutilsSetupError -from pkg_resources import EntryPoint - -from click_man.core import write_man_pages - - -__all__ = ['man_pages'] - - - -class man_pages(Command): - description = 'distutils command to generate man pages' - - user_options = [ - ('target=', 't', 'Target location for the man pages'), - ('version=', 'v', 'Version of the CLI application') - ] - boolean_options = [] - - def initialize_options(self): - self.target = os.path.join(os.getcwd(), 'man') - self.version = '' - - def finalize_options(self): - self.target = os.path.abspath(self.target) - - # create target directory if it does not exist yet - try: - os.makedirs(self.target) - except OSError: - pass - - def run(self): - """ - Generate man pages for the scripts defined in distutils setup(). - - The cli application is gathered from the setuptools setup() - function in setup.py. - - The generated man pages are written to files in the directory given - by ``--target``. - """ - eps = EntryPoint.parse_map(self.distribution.entry_points or '') - - if 'console_scripts' not in eps or not eps['console_scripts']: - raise DistutilsSetupError('No entry points defined in setup()') - - console_scripts = [(k, v) for k, v in eps['console_scripts'].items()] - # FIXME: create own setup() attribute for CLI script configuration - for name, entry_point in console_scripts: - self.announce('Load entry point {0}'.format(name), level=2) - cli = entry_point.resolve() - self.announce('Generate man pages for {0}'.format(name), level=2) - write_man_pages(name, cli, version=self.version, target_dir=self.target) From 6865fdb522086307e53a79014ebaaa8c12bb8a8c Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Wed, 17 Jan 2024 16:03:35 +0000 Subject: [PATCH 3/4] Remove use of pkg_resources This allows us to remove setuptools as a dependency. Signed-off-by: Stephen Finucane --- click_man/__main__.py | 14 -------------- click_man/core.py | 5 ++--- click_man/shell.py | 43 +++++++++++++++++++++++++++++++------------ setup.py | 1 - tests/test_shell.py | 36 ++++++++++++++++++------------------ 5 files changed, 51 insertions(+), 48 deletions(-) delete mode 100644 click_man/__main__.py diff --git a/click_man/__main__.py b/click_man/__main__.py deleted file mode 100644 index 1036141..0000000 --- a/click_man/__main__.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -click-man - Generate man pages for click application -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -This module provides the ability to run the click man command via -``python -m click_main``. - -:copyright: (c) 2016 by Timo Furrer. -:license: MIT, see LICENSE for more details. -""" - -from click_man.shell import cli - -cli() diff --git a/click_man/core.py b/click_man/core.py index fcca91d..73b24df 100644 --- a/click_man/core.py +++ b/click_man/core.py @@ -10,13 +10,12 @@ """ import os -from distutils.version import LooseVersion import click from .man import ManPage -CLICK_VERSION = tuple(int(x) for x in click.__version__.split('.')) +CLICK_VERSION = tuple(int(x) for x in click.__version__.split('.')[:2]) def get_short_help_str(command, limit=45): @@ -84,7 +83,7 @@ def write_man_pages( commands = getattr(cli, 'commands', {}) for name, command in commands.items(): - if LooseVersion(click.__version__) >= LooseVersion("7.0"): + if CLICK_VERSION >= (7, 0): # Since Click 7.0, we have been able to mark commands as hidden if command.hidden: # Do not write a man page for a hidden command diff --git a/click_man/shell.py b/click_man/shell.py index 06587b6..7ee8409 100644 --- a/click_man/shell.py +++ b/click_man/shell.py @@ -10,14 +10,35 @@ """ from datetime import datetime +import importlib.metadata import os -from pkg_resources import iter_entry_points, get_distribution +import sys +from typing import Optional import click from click_man.core import write_man_pages +def _get_entry_point(name: str) -> Optional[importlib.metadata.EntryPoint]: + entry_points = importlib.metadata.entry_points() + if sys.version_info >= (3, 10): + console_scripts = entry_points.select( + group='console_scripts', name=name + ) + else: + console_scripts = [ + ep for ep in entry_points.get('console_scripts', []) + if ep.name == name + ] + + if len(console_scripts) < 1: + return None + + # Only generate man pages for first console script + return tuple(console_scripts)[0] + + @click.command(context_settings={'help_option_names': ['-h', '--help']}) @click.option( '--target', '-t', default=os.path.join(os.getcwd(), 'man'), @@ -26,7 +47,9 @@ ) @click.option('--man-version', help='Version to use in generated man page(s)') @click.option('--man-date', help='Date to use in generated man page(s)') -@click.version_option(get_distribution('click-man').version, '-V', '--version') +@click.version_option( + importlib.metadata.version('click-man'), '-V', '--version' +) @click.argument('name') def cli(target, name, man_version, man_date): """ @@ -38,15 +61,11 @@ def cli(target, name, man_version, man_date): The generated man pages are written to files in the directory given by ``--target``. """ - console_scripts = [ - ep for ep in iter_entry_points('console_scripts', name=name) - ] - if len(console_scripts) < 1: + entry_point = _get_entry_point(name) + if not entry_point: raise click.ClickException( '"{0}" is not an installed console script.'.format(name) ) - # Only generate man pages for first console script - entry_point = console_scripts[0] # create target directory if it does not exist yet try: @@ -55,7 +74,7 @@ def cli(target, name, man_version, man_date): pass if not man_version: - man_version = entry_point.dist.version + man_version = entry_point.version if man_date: try: @@ -66,7 +85,7 @@ def cli(target, name, man_version, man_date): ) click.echo('Load entry point {0}'.format(name)) - cli = entry_point.resolve() + cli = entry_point.load() # If the entry point isn't a click.Command object, try to find it in the # module @@ -74,12 +93,12 @@ def cli(target, name, man_version, man_date): from importlib import import_module from inspect import getmembers - if not entry_point.module_name: + if not entry_point.module: raise click.ClickException( 'Could not find module name for "{0}".'.format(name) ) - ep_module = import_module(entry_point.module_name) + ep_module = import_module(entry_point.module) ep_members = getmembers( ep_module, lambda x: isinstance(x, click.Command), ) diff --git a/setup.py b/setup.py index 7369c41..0773ab3 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,6 @@ def read(fname): author_email='tuxtimo@gmail.com', install_requires=[ 'click', - 'setuptools', ], packages=find_packages(exclude=('tests', )), entry_points={ diff --git a/tests/test_shell.py b/tests/test_shell.py index 0b90fa2..98aec39 100644 --- a/tests/test_shell.py +++ b/tests/test_shell.py @@ -2,6 +2,7 @@ Module to test CLI functionality of click-man package. """ +import importlib.metadata import os from unittest import mock @@ -11,9 +12,9 @@ from click_man import shell -@mock.patch.object(shell, 'iter_entry_points') -def test_missing_entry_point(mock_entry_points): - mock_entry_points.return_value = iter([]) +@mock.patch.object(shell, '_get_entry_point') +def test_cli__missing_entry_point(mock_entry_point): + mock_entry_point.return_value = None runner = CLIRunner() result = runner.invoke(shell.cli, 'foo') @@ -21,29 +22,29 @@ def test_missing_entry_point(mock_entry_points): assert result.exit_code == 1, result assert 'not an installed console script' in result.output.strip() - mock_entry_points.assert_called_once_with('console_scripts', name='foo') + mock_entry_point.assert_called_once_with('foo') @mock.patch('os.makedirs', new=mock.Mock()) @mock.patch.object(shell, 'write_man_pages') @mock.patch.object(click, 'echo') -@mock.patch.object(shell, 'iter_entry_points') -def test_is_click_command(mock_entry_points, mock_echo, mock_write): +@mock.patch.object(shell, '_get_entry_point') +def test_cli__valid(mock_entry_point, mock_echo, mock_write): fake_target = os.path.join(os.getcwd(), 'man') fake_command = click.Command(name='foo') fake_version = '1.2.3' - entry_point = mock.Mock() - entry_point.resolve.return_value = fake_command - entry_point.dist.version = fake_version + entry_point = mock.Mock(spec=importlib.metadata.EntryPoint) + entry_point.load.return_value = fake_command + entry_point.version = fake_version - mock_entry_points.return_value = iter([entry_point]) + mock_entry_point.return_value = entry_point runner = CLIRunner() result = runner.invoke(shell.cli, ['foo']) assert result.exit_code == 0, result.output - mock_entry_points.assert_called_once_with('console_scripts', name='foo') + mock_entry_point.assert_called_once_with('foo') mock_echo.assert_has_calls([ mock.call('Load entry point foo'), mock.call('Generate man pages for foo in %s' % fake_target), @@ -57,14 +58,14 @@ def test_is_click_command(mock_entry_points, mock_echo, mock_write): @mock.patch('os.makedirs', new=mock.Mock()) @mock.patch.object(shell, 'write_man_pages') @mock.patch.object(click, 'echo') -@mock.patch.object(shell, 'iter_entry_points') -def test_man_date_version(mock_entry_points, mock_echo, mock_write): +@mock.patch.object(shell, '_get_entry_point') +def test_cli__with_man_date_version(mock_entry_point, mock_echo, mock_write): fake_target = os.path.join(os.getcwd(), 'man') fake_command = click.Command(name='foo') - entry_point = mock.Mock() - entry_point.resolve.return_value = fake_command + entry_point = mock.Mock(spec=importlib.metadata.EntryPoint) + entry_point.load.return_value = fake_command - mock_entry_points.return_value = iter([entry_point]) + mock_entry_point.return_value = entry_point runner = CLIRunner() result = runner.invoke( @@ -74,8 +75,7 @@ def test_man_date_version(mock_entry_points, mock_echo, mock_write): assert result.exit_code == 0, result.output - mock_entry_points.assert_called_once_with('console_scripts', name='foo') - entry_point.dist.version.assert_not_called() + mock_entry_point.assert_called_once_with('foo') mock_echo.assert_has_calls([ mock.call('Load entry point foo'), mock.call('Generate man pages for foo in %s' % fake_target), From 84678430e536d53683cdb5e227937ed323310d62 Mon Sep 17 00:00:00 2001 From: Stephen Finucane Date: Tue, 10 Dec 2024 10:32:19 +0000 Subject: [PATCH 4/4] Drop support for Python < 3.9 We also update the CI test matrix to reflect this. Signed-off-by: Stephen Finucane --- .github/workflows/ci.yaml | 4 ++-- setup.py | 3 ++- tox.ini | 6 ++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 96c0358..6564773 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python: ['3.7', '3.8', '3.9', '3.10'] + python: ['3.9', '3.10', '3.11', '3.12', '3.13'] steps: - name: Checkout source code uses: actions/checkout@v3 @@ -37,7 +37,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.13' - name: Install dependencies run: python -m pip install build - name: Build a binary wheel and a source tarball diff --git a/setup.py b/setup.py index 0773ab3..22731ad 100644 --- a/setup.py +++ b/setup.py @@ -25,13 +25,14 @@ def read(fname): 'click-man = click_man.__main__:cli', ] }, + python_requires='>=3.9', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 2', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3 :: Only', 'Topic :: Documentation', ], ) diff --git a/tox.ini b/tox.ini index a125739..3cc7717 100644 --- a/tox.ini +++ b/tox.ini @@ -4,12 +4,10 @@ # and then run "tox" from this directory. [tox] -minversion = 3.1 -envlist = py37,py38,py39,py310 -ignore_basepython_conflict = true +minversion = 4.4 +envlist = py39,py310,py311,py312,py313 [testenv] -basepython = python3 deps = pytest pytest-cov