From aa372253bc217dae237d8b69325a4ed772c7caf5 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sun, 6 Oct 2024 12:01:00 -0700 Subject: [PATCH 01/36] update changelog --- doc/source/changelog.rst | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 5b332cec..33c14f7f 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,6 +2,13 @@ Change Log ========== +v2.2.3 (2024-10-xx) +=================== + +* Completed `Open up vulnerability reporting and add security policy. `_ +* Completed `Move architecture in docs to ARCHITECTURE.md `_ + + v2.2.2 (2024-08-25) ==================== From 6dba599b6c4989aacc04b840f5bbf20e63056538 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 7 Oct 2024 14:14:26 -0500 Subject: [PATCH 02/36] Add Django Commons release GHA --- .github/workflows/release.yml | 96 +++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..5bb80d4e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,96 @@ +name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI + +on: push + +env: + # Change these for your project's URLs + PYPI_URL: https://pypi.org/p/django-typer + # Eventually add back the test release, see example at + # https://github.com/django-commons/django-commons-playground/blob/main/.github/workflows/release.yml + +jobs: + + build: + name: Build distribution 📦 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install pypa/build + run: + python3 -m pip install build --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v3 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python 🐍 distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: ${{ env.PYPI_URL }} + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to PyPI + uses: pypa/gh-action-pypi-publish@release/v1.10 + + github-release: + name: >- + Sign the Python 🐍 distribution 📦 with Sigstore + and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' From 951d8df0c638d88b378cb8aea140c52d643de1ba Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 7 Oct 2024 14:22:36 -0500 Subject: [PATCH 03/36] Switch to project section in pyproject.toml This will break releasing by poetry. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a691571c..f1f3c6d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -[tool.poetry] +[project] name = "django-typer" version = "2.2.2" description = "Use Typer to define the CLI for your Django management commands." From 889191d7d1d2cd46c7378e118c9b22759bc213bd Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 7 Oct 2024 14:24:42 -0500 Subject: [PATCH 04/36] Fix license to be what PyPI wants https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#license --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f1f3c6d2..d680783c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "django-typer" version = "2.2.2" description = "Use Typer to define the CLI for your Django management commands." authors = ["Brian Kohan "] -license = "MIT" +license = {text = "MIT License"} readme = "README.md" repository = "https://github.com/bckohan/django-typer" homepage = "https://django-typer.readthedocs.io" From 8b254d2bf2e9fa8e4eb7bf618a38763e723fc012 Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 7 Oct 2024 14:26:54 -0500 Subject: [PATCH 05/36] Fix authors to match what PyPI wants https://packaging.python.org/en/latest/guides/writing-pyproject-toml/#a-full-example --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d680783c..56d69d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,7 @@ name = "django-typer" version = "2.2.2" description = "Use Typer to define the CLI for your Django management commands." -authors = ["Brian Kohan "] +authors = [{name = "Brian Kohan", email = "bckohan@gmail.com"}] license = {text = "MIT License"} readme = "README.md" repository = "https://github.com/bckohan/django-typer" From 0e58db381dbd18044661daf0f92d51eaedd1573b Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Mon, 7 Oct 2024 14:29:07 -0500 Subject: [PATCH 06/36] Move packages and exclude up to project section --- pyproject.toml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 56d69d74..adfc95e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,10 @@ readme = "README.md" repository = "https://github.com/bckohan/django-typer" homepage = "https://django-typer.readthedocs.io" keywords = ["django", "CLI", "management", "Typer", "commands"] +packages = [ + { include = "django_typer" } +] +exclude = ["django_typer/locale"] classifiers = [ "Environment :: Console", "Framework :: Django", @@ -45,10 +49,6 @@ Issues = "https://github.com/bckohan/django-typer/issues" Changelog = "https://django-typer.readthedocs.io/en/latest/changelog.html" Code_of_Conduct = "https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md" -packages = [ - { include = "django_typer" } -] -exclude = ["django_typer/locale"] [tool.poetry.dependencies] python = ">=3.8,<4.0" From 8a3db32de373f3b2796d43e75ecd30de85e60fa2 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Mon, 7 Oct 2024 15:33:41 -0400 Subject: [PATCH 07/36] update to match django-tasks --- pyproject.toml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index adfc95e8..0a4a6598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,8 @@ -[project] +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] name = "django-typer" version = "2.2.2" description = "Use Typer to define the CLI for your Django management commands." @@ -41,13 +45,13 @@ classifiers = [ ] -[project.urls] -Homepage = "https://django-typer.readthedocs.io" -Documentation = "https://django-typer.readthedocs.io" -Repository = "https://github.com/bckohan/django-typer" -Issues = "https://github.com/bckohan/django-typer/issues" -Changelog = "https://django-typer.readthedocs.io/en/latest/changelog.html" -Code_of_Conduct = "https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md" +[tool.poetry.urls] +"Homepage" = "https://django-typer.readthedocs.io" +"Documentation" = "https://django-typer.readthedocs.io" +"Repository" = "https://github.com/bckohan/django-typer" +"Issues" = "https://github.com/bckohan/django-typer/issues" +"Changelog" = "https://django-typer.readthedocs.io/en/latest/changelog.html" +"Code_of_Conduct" = "https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md" [tool.poetry.dependencies] From 2bb501bc83d8ee5661843246ffacfbd38607ed86 Mon Sep 17 00:00:00 2001 From: Daniel M Date: Mon, 7 Oct 2024 15:35:16 -0400 Subject: [PATCH 08/36] update authors and license --- pyproject.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0a4a6598..8da4a5e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,10 @@ build-backend = "poetry.core.masonry.api" name = "django-typer" version = "2.2.2" description = "Use Typer to define the CLI for your Django management commands." -authors = [{name = "Brian Kohan", email = "bckohan@gmail.com"}] -license = {text = "MIT License"} +authors = [ + "Brian Kohan ", +] +license = "MIT" readme = "README.md" repository = "https://github.com/bckohan/django-typer" homepage = "https://django-typer.readthedocs.io" From 93e0c2017f308435b9932158cd1855d8d434074b Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Mon, 7 Oct 2024 16:46:51 -0700 Subject: [PATCH 09/36] add test.pypi repo to release --- .github/workflows/release.yml | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5bb80d4e..9bfdd0b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,12 +1,15 @@ +## +# Derived from: +# https://github.com/django-commons/django-commons-playground/blob/main/.github/workflows/release.yml +# + name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI on: push env: - # Change these for your project's URLs PYPI_URL: https://pypi.org/p/django-typer - # Eventually add back the test release, see example at - # https://github.com/django-commons/django-commons-playground/blob/main/.github/workflows/release.yml + PYPI_TEST_URL: https://test.pypi.org/project/django-typer jobs: @@ -94,3 +97,28 @@ jobs: gh release upload '${{ github.ref_name }}' dist/** --repo '${{ github.repository }}' + + publish-to-testpypi: + name: Publish Python 🐍 distribution 📦 to TestPyPI + needs: + - build + runs-on: ubuntu-latest + + environment: + name: testpypi + url: ${{ env.PYPI_TEST_URL }} + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v3 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution 📦 to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1.10 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true From 53a7e6d15161ea451d4e152524b74370548b38b9 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 20:55:09 -0700 Subject: [PATCH 10/36] upgrade python setup action --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9bfdd0b0..89fe3af1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.x" - name: Install pypa/build From b92830d52c647d75ecc11a862a63d4e0d2d4b132 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 21:01:49 -0700 Subject: [PATCH 11/36] add django packages badge to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index acdb48ee..705fd498 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ [![Code Cov](https://codecov.io/gh/bckohan/django-typer/branch/main/graph/badge.svg?token=0IZOKN2DYL)](https://codecov.io/gh/bckohan/django-typer) [![Test Status](https://github.com/bckohan/django-typer/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/bckohan/django-typer/actions/workflows/test.yml?query=branch:main) [![Lint Status](https://github.com/bckohan/django-typer/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/bckohan/django-typer/actions/workflows/lint.yml?query=branch:main) +[![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-typer/) Use static typing to define the CLI for your [Django](https://www.djangoproject.com/) management commands with [Typer](https://typer.tiangolo.com/). Optionally use the provided [TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) class that inherits from [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand). This class maps the [Typer](https://typer.tiangolo.com/) interface onto a class based interface that Django developers will be familiar with. All of the [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand) functionality is preserved, so that [TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) can be a drop in replacement. From fad3e7446a4a59772ef8a237c8a095e052aa4548 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 21:05:03 -0700 Subject: [PATCH 12/36] add ruff_cache to get ignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 87f0dc29..b2764981 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,4 @@ poetry.lock .vscode django_typer/tests/dj_params.json **/.DS_Store +.ruff_cache From 55fdb74694cd4d66daf62d531fea6126f45d4716 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 21:07:49 -0700 Subject: [PATCH 13/36] add 3.13 to CI matrix, only publish to test pypi on release tags --- .github/workflows/lint.yml | 6 +++--- .github/workflows/release.yml | 1 + .github/workflows/test.yml | 10 +++++----- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d238410f..9b46cd20 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: strategy: matrix: # run static analysis on bleeding and trailing edges - python-version: [ '3.8', '3.10', '3.12' ] + python-version: [ '3.8', '3.10', '3.13' ] django-version: - '3.2' # LTS April 2024 - '4.2' # LTS April 2026 @@ -27,9 +27,9 @@ jobs: exclude: - python-version: '3.8' django-version: '4.2' - - python-version: '3.12' + - python-version: '3.13' django-version: '4.2' - - python-version: '3.12' + - python-version: '3.13' django-version: '3.2' - python-version: '3.10' django-version: '3.2' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 89fe3af1..d0035440 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,6 +100,7 @@ jobs: publish-to-testpypi: name: Publish Python 🐍 distribution 📦 to TestPyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6733635e..8158fecf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13.0-rc.2'] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] django-version: - '3.2' # LTS April 2024 - '4.2' # LTS April 2026 @@ -38,11 +38,11 @@ jobs: django-version: '5.1' - python-version: '3.9' django-version: '5.1' - - python-version: '3.13.0-rc.2' + - python-version: '3.13' django-version: '3.2' - - python-version: '3.13.0-rc.2' + - python-version: '3.13' django-version: '4.2' - - python-version: '3.13.0-rc.2' + - python-version: '3.13' django-version: '5.0' steps: @@ -58,7 +58,7 @@ jobs: virtualenvs-create: true virtualenvs-in-project: true - name: Install libopenblas-dev - if: matrix.python-version == '3.13.0-rc.2' + if: matrix.python-version == '3.13' run: sudo apt-get install libopenblas-dev - name: Install Release Dependencies run: | From f4b4134cd8f6bc6d91b088ff5a7bb21a4361bf69 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 21:11:12 -0700 Subject: [PATCH 14/36] upgrade gh actions on release action --- .github/workflows/release.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0035440..87c50542 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: - name: Build a binary wheel and a source tarball run: python3 -m build - name: Store the distribution packages - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: python-package-distributions path: dist/ @@ -48,7 +48,7 @@ jobs: id-token: write # IMPORTANT: mandatory for trusted publishing steps: - name: Download all the dists - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ @@ -69,7 +69,7 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ @@ -114,7 +114,7 @@ jobs: steps: - name: Download all the dists - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 with: name: python-package-distributions path: dist/ From 4ab3082b134f9db369e25a2d88a80e4df8ae3af9 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 21:28:26 -0700 Subject: [PATCH 15/36] update repo links --- README.md | 24 +++--- SECURITY.md | 2 +- django_typer/management/__init__.py | 2 +- doc/source/changelog.rst | 113 ++++++++++++++-------------- doc/source/conf.py | 2 +- doc/source/refs.rst | 2 +- doc/source/shell_completion.rst | 4 +- pyproject.toml | 6 +- tests/test_groups.py | 2 +- 9 files changed, 79 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index 705fd498..b8a51a5e 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,9 @@ [![PyPI djversions](https://img.shields.io/pypi/djversions/django-typer.svg)](https://pypi.org/project/django-typer/) [![PyPI status](https://img.shields.io/pypi/status/django-typer.svg)](https://pypi.python.org/pypi/django-typer) [![Documentation Status](https://readthedocs.org/projects/django-typer/badge/?version=latest)](http://django-typer.readthedocs.io/?badge=latest/) -[![Code Cov](https://codecov.io/gh/bckohan/django-typer/branch/main/graph/badge.svg?token=0IZOKN2DYL)](https://codecov.io/gh/bckohan/django-typer) -[![Test Status](https://github.com/bckohan/django-typer/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/bckohan/django-typer/actions/workflows/test.yml?query=branch:main) -[![Lint Status](https://github.com/bckohan/django-typer/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/bckohan/django-typer/actions/workflows/lint.yml?query=branch:main) +[![Code Cov](https://codecov.io/gh/django-commons/django-typer/branch/main/graph/badge.svg?token=0IZOKN2DYL)](https://codecov.io/gh/django-commons/django-typer) +[![Test Status](https://github.com/django-commons/django-typer/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/django-commons/django-typer/actions/workflows/test.yml?query=branch:main) +[![Lint Status](https://github.com/django-commons/django-typer/actions/workflows/lint.yml/badge.svg?branch=main)](https://github.com/django-commons/django-typer/actions/workflows/lint.yml?query=branch:main) [![Published on Django Packages](https://img.shields.io/badge/Published%20on-Django%20Packages-0c3c26)](https://djangopackages.org/packages/p/django-typer/) Use static typing to define the CLI for your [Django](https://www.djangoproject.com/) management commands with [Typer](https://typer.tiangolo.com/). Optionally use the provided [TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) class that inherits from [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand). This class maps the [Typer](https://typer.tiangolo.com/) interface onto a class based interface that Django developers will be familiar with. All of the [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand) functionality is preserved, so that [TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) can be a drop in replacement. @@ -30,7 +30,7 @@ Use static typing to define the CLI for your [Django](https://www.djangoproject. Please refer to the [full documentation](https://django-typer.readthedocs.io/) for more information. -![django-typer example](https://raw.githubusercontent.com/bckohan/django-typer/main/doc/source/_static/img/closepoll_example.gif) +![django-typer example](https://raw.githubusercontent.com/django-commons/django-typer/main/doc/source/_static/img/closepoll_example.gif) ## 🚨 Deprecation Notice @@ -97,7 +97,7 @@ def main(arg1: str, arg2: str, arg3: float = 0.5, arg4: int = 1): """ ``` -![Basic Example](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/basic.svg) +![Basic Example](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/basic.svg) ## Multiple Subcommands Example @@ -166,9 +166,9 @@ def delete( """ ``` -![Multiple Subcommands Example](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/multi.svg) -![Multiple Subcommands Example - create](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/multi_create.svg) -![Multiple Subcommands Example - delete](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/multi_delete.svg) +![Multiple Subcommands Example](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/multi.svg) +![Multiple Subcommands Example - create](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/multi_create.svg) +![Multiple Subcommands Example - delete](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/multi_delete.svg) ## Grouping and Hierarchies Example @@ -289,7 +289,7 @@ def divide( return f"{numerator / denominator:.{self.precision}f}" ``` -![Grouping and Hierarchies Example](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/hierarchy.svg) -![Grouping and Hierarchies Example - math](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/hierarchy_math.svg) -![Grouping and Hierarchies Example - math multiply](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/hierarchy_math_multiply.svg) -![Grouping and Hierarchies Example - math divide](https://raw.githubusercontent.com/bckohan/django-typer/main/examples/helps/hierarchy_math_divide.svg) +![Grouping and Hierarchies Example](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/hierarchy.svg) +![Grouping and Hierarchies Example - math](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/hierarchy_math.svg) +![Grouping and Hierarchies Example - math multiply](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/hierarchy_math_multiply.svg) +![Grouping and Hierarchies Example - math divide](https://raw.githubusercontent.com/django-commons/django-typer/main/examples/helps/hierarchy_math_divide.svg) diff --git a/SECURITY.md b/SECURITY.md index 567a2dad..2f9e8b29 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,4 +6,4 @@ Only the latest version of django-typer [![PyPI version](https://badge.fury.io/p ## Reporting a Vulnerability -If you think you have found a vulnerability, and even if you are not sure, please [report it to us in private](https://github.com/bckohan/django-typer/security/advisories/new). We will review it and get back to you. Please refrain from public discussions of the issue. +If you think you have found a vulnerability, and even if you are not sure, please [report it to us in private](https://github.com/django-commons/django-typer/security/advisories/new). We will review it and get back to you. Please refrain from public discussions of the issue. diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index c782c0f5..dd57d231 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -847,7 +847,7 @@ def django_command(self, cmd: t.Optional[t.Type["TyperCommand"]]): # todo - this results in type hinting expecting self to be passed explicitly # when this is called as a callable - # https://github.com/bckohan/django-typer/issues/73 + # https://github.com/django-commons/django-typer/issues/73 def __get__(self, obj, _=None) -> "Typer[P, R]": """ Our Typer app wrapper also doubles as a descriptor, so when diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 33c14f7f..ebc58b37 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -5,41 +5,42 @@ Change Log v2.2.3 (2024-10-xx) =================== -* Completed `Open up vulnerability reporting and add security policy. `_ -* Completed `Move architecture in docs to ARCHITECTURE.md `_ - +* Completed `Add project to test PyPI `_ +* Completed `Open up vulnerability reporting and add security policy. `_ +* Completed `Move architecture in docs to ARCHITECTURE.md `_ +* Completed `Transfer to django-commons `_ v2.2.2 (2024-08-25) ==================== -* Implemented `Support python 3.13 `_ -* Fixed `typer > 0.12.5 toggles rich help renderings off by default `_ +* Implemented `Support python 3.13 `_ +* Fixed `typer > 0.12.5 toggles rich help renderings off by default `_ v2.2.1 (2024-08-17) ==================== -* Fixed `Remove shell_complete monkey patch when upstream PR is merged. `_ +* Fixed `Remove shell_complete monkey patch when upstream PR is merged. `_ v2.2.0 (2024-07-26) ==================== -* Implemented `ModelObjectCompleter should optionally accept a QuerySet in place of a Model class. `_ +* Implemented `ModelObjectCompleter should optionally accept a QuerySet in place of a Model class. `_ v2.1.3 (2024-07-15) ==================== -* Fixed `Move from django_typer to django_typer.management broke doc reference links. `_ -* Implemented `Support Django 5.1 `_ +* Fixed `Move from django_typer to django_typer.management broke doc reference links. `_ +* Implemented `Support Django 5.1 `_ v2.1.2 (2024-06-07) ==================== -* Fixed `Type hint kwargs to silence pylance warnings about partially unknown types `_ +* Fixed `Type hint kwargs to silence pylance warnings about partially unknown types `_ v2.1.1 (2024-06-06) ==================== -* Fixed `handle = None does not work for mypy to silence type checkers `_ +* Fixed `handle = None does not work for mypy to silence type checkers `_ v2.1.0 (2024-06-05) ==================== @@ -57,21 +58,21 @@ v2.1.0 (2024-06-05) # new way! from django_typer.management import TyperCommand, command, group, initialize, Typer -* Implemented `Only attempt to import rich and typer if settings has not disabled tracebacks. `_ -* Implemented `Move tests into top level dir. `_ -* Implemented `Move core code out of __init__.py into management/__init__.py `_ -* Fixed `Typer(help="") doesnt work. `_ +* Implemented `Only attempt to import rich and typer if settings has not disabled tracebacks. `_ +* Implemented `Move tests into top level dir. `_ +* Implemented `Move core code out of __init__.py into management/__init__.py `_ +* Fixed `Typer(help="") doesnt work. `_ v2.0.2 (2024-06-03) ==================== -* Fixed `class help attribute should be type hinted to allow a lazy translation string. `_ +* Fixed `class help attribute should be type hinted to allow a lazy translation string. `_ v2.0.1 (2024-05-31) ==================== -* Fixed `Readme images are broken. `_ +* Fixed `Readme images are broken. `_ v2.0.0 (2024-05-31) ==================== @@ -79,97 +80,97 @@ v2.0.0 (2024-05-31) This major version release, includes an extensive internal refactor, numerous bug fixes and the addition of a plugin-based extension pattern. -* Fixed `Stack trace produced when attempted to tab-complete a non-existent management command. `_ -* Fixed `Overriding handle() in inherited commands results in multiple commands. `_ -* Implemented `Support subgroup name overloads. `_ -* Fixed `Helps from class docstrings and TyperCommand class parameters are not inherited. `_ -* Implemented `Allow callback and initialize to be aliases of each other. `_ -* Implemented `Shell completion for --pythonpath `_ -* Implemented `Shell completion for --settings `_ -* Fixed `An intelligible exception should be thrown when a command is invoked that has no implementation. `_ -* Implemented `TyperCommand class docstring should be used as the help as a last resort. `_ -* Implemented `Adapter pattern that allows commands and groups to be added without extension by apps further up the app stack. `_ -* Fixed `ModelObjectParser should use a metavar appropriate to the field type. `_ -* Implemented `Switch to ruff for linting and formatting. `_ -* Implemented `Add a wrapper for typer's echo/secho `_ -* Implemented `Support a native typer-like interface. `_ -* Fixed `@group type hint does not carry over the parameter spec of the wrapped function `_ -* Implemented `Better test organization. `_ -* Implemented `Add completer/parser for GenericIPAddressField. `_ +* Fixed `Stack trace produced when attempted to tab-complete a non-existent management command. `_ +* Fixed `Overriding handle() in inherited commands results in multiple commands. `_ +* Implemented `Support subgroup name overloads. `_ +* Fixed `Helps from class docstrings and TyperCommand class parameters are not inherited. `_ +* Implemented `Allow callback and initialize to be aliases of each other. `_ +* Implemented `Shell completion for --pythonpath `_ +* Implemented `Shell completion for --settings `_ +* Fixed `An intelligible exception should be thrown when a command is invoked that has no implementation. `_ +* Implemented `TyperCommand class docstring should be used as the help as a last resort. `_ +* Implemented `Adapter pattern that allows commands and groups to be added without extension by apps further up the app stack. `_ +* Fixed `ModelObjectParser should use a metavar appropriate to the field type. `_ +* Implemented `Switch to ruff for linting and formatting. `_ +* Implemented `Add a wrapper for typer's echo/secho `_ +* Implemented `Support a native typer-like interface. `_ +* Fixed `@group type hint does not carry over the parameter spec of the wrapped function `_ +* Implemented `Better test organization. `_ +* Implemented `Add completer/parser for GenericIPAddressField. `_ v1.1.2 (2024-04-22) ==================== -* Fixed `Overridden common Django arguments fail to pass through when passed through call_command `_ +* Fixed `Overridden common Django arguments fail to pass through when passed through call_command `_ v1.1.1 (2024-04-11) ==================== -* Implemented `Fix pyright type checking and add to CI `_ -* Implemented `Convert CONTRIBUTING.rst to markdown `_ +* Implemented `Fix pyright type checking and add to CI `_ +* Implemented `Convert CONTRIBUTING.rst to markdown `_ v1.1.0 (2024-04-03) ==================== -* Implemented `Convert readme to markdown. `_ -* Fixed `typer 0.12.0 breaks django_typer 1.0.9 `_ +* Implemented `Convert readme to markdown. `_ +* Fixed `typer 0.12.0 breaks django_typer 1.0.9 `_ v1.0.9 (yanked) =============== -* Fixed `Support typer 0.12.0 `_ +* Fixed `Support typer 0.12.0 `_ v1.0.8 (2024-03-26) ==================== -* Fixed `Support typer 0.10 and 0.11 `_ +* Fixed `Support typer 0.10 and 0.11 `_ v1.0.7 (2024-03-17) ==================== -* Fixed `Helps throw an exception when invoked from an absolute path that is not relative to the getcwd() `_ +* Fixed `Helps throw an exception when invoked from an absolute path that is not relative to the getcwd() `_ v1.0.6 (2024-03-14) ==================== -* Fixed `prompt options on groups still prompt when given as named parameters on call_command `_ +* Fixed `prompt options on groups still prompt when given as named parameters on call_command `_ v1.0.5 (2024-03-14) ==================== -* Fixed `Options with prompt=True are prompted twice `_ +* Fixed `Options with prompt=True are prompted twice `_ v1.0.4 (2024-03-13) ==================== -* Fixed `Help sometimes shows full script path in Usage: when it shouldnt. `_ -* Fixed `METAVAR when ModelObjectParser supplied should default to model name `_ +* Fixed `Help sometimes shows full script path in Usage: when it shouldnt. `_ +* Fixed `METAVAR when ModelObjectParser supplied should default to model name `_ v1.0.3 (2024-03-08) ==================== -* Fixed `Incomplete typing info for @command decorator `_ +* Fixed `Incomplete typing info for @command decorator `_ v1.0.2 (2024-03-05) ==================== -* Fixed `name property on TyperCommand is too generic and should be private. `_ -* Fixed `When usage errors are thrown the help output should be that of the subcommand invoked not the parent group. `_ -* Fixed `typer installs its own system exception hook when commands are run and this may step on the installed rich hook `_ -* Fixed `Add py.typed stub `_ -* Fixed `Run type checking with django-stubs installed. `_ -* Fixed `Add pyright to linting and resolve any pyright errors. `_ -* Fixed `Missing subcommand produces stack trace without --traceback. `_ -* Fixed `Allow handle() to be an initializer. `_ +* Fixed `name property on TyperCommand is too generic and should be private. `_ +* Fixed `When usage errors are thrown the help output should be that of the subcommand invoked not the parent group. `_ +* Fixed `typer installs its own system exception hook when commands are run and this may step on the installed rich hook `_ +* Fixed `Add py.typed stub `_ +* Fixed `Run type checking with django-stubs installed. `_ +* Fixed `Add pyright to linting and resolve any pyright errors. `_ +* Fixed `Missing subcommand produces stack trace without --traceback. `_ +* Fixed `Allow handle() to be an initializer. `_ v1.0.1 (2024-02-29) ==================== -* Fixed `shell_completion broken for click < 8.1 `_ +* Fixed `shell_completion broken for click < 8.1 `_ v1.0.0 (2024-02-26) ==================== diff --git a/doc/source/conf.py b/doc/source/conf.py index 9ec74c1a..9bd66f8d 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -71,7 +71,7 @@ # html_theme = 'sphinx_rtd_theme' html_theme = 'furo' html_theme_options = { - "source_repository": "https://github.com/bckohan/django-typer/", + "source_repository": "https://github.com/django-commons/django-typer/", "source_branch": "main", "source_directory": "doc/source", } diff --git a/doc/source/refs.rst b/doc/source/refs.rst index ef4f905e..9cee1916 100644 --- a/doc/source/refs.rst +++ b/doc/source/refs.rst @@ -1,6 +1,6 @@ .. _Django: https://www.djangoproject.com/ -.. _GitHub: https://github.com/bckohan/django-typer +.. _GitHub: https://github.com/django-commons/django-typer .. _PyPI: https://pypi.python.org/pypi/django-typer .. _Typer: https://typer.tiangolo.com/ .. _click: https://click.palletsprojects.com/ diff --git a/doc/source/shell_completion.rst b/doc/source/shell_completion.rst index d7adb230..3897822d 100644 --- a/doc/source/shell_completion.rst +++ b/doc/source/shell_completion.rst @@ -56,7 +56,7 @@ the shell. This process has two phases: The goal of this guide is not to be an exhaustive list of how to enable completions for each supported shell on all possible platforms, but rather to provide general guidance on how to enable completions for the most common platforms and environments. If you encounter issues -or have solutions, please `report them on our issues page `_ +or have solutions, please `report them on our issues page `_ Windows ------- @@ -146,7 +146,7 @@ However, if you are using a separate package to define custom tab completions fo you may use the --fallback parameter to supply a separate fallback hook that will invoke the appropriate completion function for your commands. If there are other popular completion libraries please consider `letting us know or submitting a PR -`_ to support these libraries as a fallback out of +`_ to support these libraries as a fallback out of the box. diff --git a/pyproject.toml b/pyproject.toml index 8da4a5e4..c564c74e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,7 @@ authors = [ ] license = "MIT" readme = "README.md" -repository = "https://github.com/bckohan/django-typer" +repository = "https://github.com/django-commons/django-typer" homepage = "https://django-typer.readthedocs.io" keywords = ["django", "CLI", "management", "Typer", "commands"] packages = [ @@ -50,8 +50,8 @@ classifiers = [ [tool.poetry.urls] "Homepage" = "https://django-typer.readthedocs.io" "Documentation" = "https://django-typer.readthedocs.io" -"Repository" = "https://github.com/bckohan/django-typer" -"Issues" = "https://github.com/bckohan/django-typer/issues" +"Repository" = "https://github.com/django-commons/django-typer" +"Issues" = "https://github.com/django-commons/django-typer/issues" "Changelog" = "https://django-typer.readthedocs.io/en/latest/changelog.html" "Code_of_Conduct" = "https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md" diff --git a/tests/test_groups.py b/tests/test_groups.py index dca02f61..52fc4415 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -41,7 +41,7 @@ def test_get_help_from_incongruent_path(self): directories get included because their relative paths dont resolve in the coverage output for this test. VERY ANNOYING - not sure how to fix? - https://github.com/bckohan/django-typer/issues/44 + https://github.com/django-commons/django-typer/issues/44 """ # change dir to the first dir that is not a parent cwd = Path(os.getcwd()) From 45c01c177a3f5281dd2a3a969a5d0b36efb93b12 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 22:58:15 -0700 Subject: [PATCH 16/36] remove heavy weight dependencies and compute text cosine similarity in pure python --- .github/workflows/lint.yml | 5 ----- .github/workflows/test.yml | 3 --- pyproject.toml | 12 ------------ tests/utils.py | 35 ++++++++++++++++++++++++++++------- 4 files changed, 28 insertions(+), 27 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 9b46cd20..fb470f44 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -22,7 +22,6 @@ jobs: django-version: - '3.2' # LTS April 2024 - '4.2' # LTS April 2026 - - '5.0' # April 2025 - '5.1' # December 2025 exclude: - python-version: '3.8' @@ -33,10 +32,6 @@ jobs: django-version: '3.2' - python-version: '3.10' django-version: '3.2' - - python-version: '3.8' - django-version: '5.0' - - python-version: '3.10' - django-version: '5.0' - python-version: '3.8' django-version: '5.1' - python-version: '3.10' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8158fecf..71df607d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,9 +57,6 @@ jobs: with: virtualenvs-create: true virtualenvs-in-project: true - - name: Install libopenblas-dev - if: matrix.python-version == '3.13' - run: sudo apt-get install libopenblas-dev - name: Install Release Dependencies run: | poetry config virtualenvs.in-project true diff --git a/pyproject.toml b/pyproject.toml index c564c74e..5d457fd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,19 +94,7 @@ doc8 = ">=1.1.1" aiohttp = ">=3.9.1" readme-renderer = {extras = ["md"], version = ">=42"} sphinxcontrib-typer = {extras = ["html", "pdf", "png"], version = ">=0.5.0", markers="python_version >= '3.9'"} -scikit-learn = [ - { version = ">=1.5", markers = "python_version > '3.8'" }, - { version = ">=1.0", markers = "python_version <= '3.8'" }, -] pytest-env = ">=1.0.0" -numpy = [ - { version = ">=1.26", markers = "python_version > '3.8'" }, - { version = "<=1.24", markers = "python_version <= '3.8'" }, -] -scipy = [ - { version = ">=1.11", markers = "python_version > '3.8'" }, - { version = "<=1.10", markers = "python_version <= '3.8'" }, -] django-stubs = ">=4.2.7" pexpect = ">=4.9.0" pyright = ">=1.1.357" diff --git a/tests/utils.py b/tests/utils.py index f4d6aa09..ea642df4 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,11 +5,12 @@ import sys from pathlib import Path from typing import Tuple - +import re +import math +import re +from collections import Counter import pexpect from django.core.management.color import no_style -from sklearn.feature_extraction.text import TfidfVectorizer -from sklearn.metrics.pairwise import cosine_similarity try: import rich @@ -22,6 +23,26 @@ TESTS_DIR = Path(__file__).parent DJANGO_PARAMETER_LOG_FILE = TESTS_DIR / "dj_params.json" manage_py = TESTS_DIR.parent / "manage.py" +WORD = re.compile(r"\w+") + + +def get_cosine(vec1, vec2): + intersection = set(vec1.keys()) & set(vec2.keys()) + numerator = sum([vec1[x] * vec2[x] for x in intersection]) + + sum1 = sum([vec1[x] ** 2 for x in list(vec1.keys())]) + sum2 = sum([vec2[x] ** 2 for x in list(vec2.keys())]) + denominator = math.sqrt(sum1) * math.sqrt(sum2) + + if not denominator: + return 0.0 + else: + return float(numerator) / denominator + + +def text_to_vector(text): + words = WORD.findall(text) + return Counter(words) def similarity(text1, text2): @@ -31,10 +52,10 @@ def similarity(text1, text2): We use this to lazily evaluate the output of --help to our renderings. - """ - vectorizer = TfidfVectorizer() - tfidf_matrix = vectorizer.fit_transform([text1, text2]) - return cosine_similarity(tfidf_matrix[0:1], tfidf_matrix[1:2])[0][0] + #""" + vector1 = text_to_vector(text1) + vector2 = text_to_vector(text2) + return get_cosine(vector1, vector2) def get_named_arguments(function): From c33cba31ae6800e2c7f84ede167497763c699fb2 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 23:02:12 -0700 Subject: [PATCH 17/36] only run release workflow on tag creation --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87c50542..6ac76834 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,7 @@ jobs: build: name: Build distribution 📦 + if: startsWith(github.ref, 'refs/tags/') # only publish on tag pushes runs-on: ubuntu-latest steps: @@ -37,7 +38,6 @@ jobs: publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest @@ -100,7 +100,6 @@ jobs: publish-to-testpypi: name: Publish Python 🐍 distribution 📦 to TestPyPI - if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes needs: - build runs-on: ubuntu-latest From 787bd54ee62964c22c89cd71da17e5b3a622785f Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 23:21:11 -0700 Subject: [PATCH 18/36] ci updates --- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ac76834..b8878879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ # https://github.com/django-commons/django-commons-playground/blob/main/.github/workflows/release.yml # -name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI +name: Publish to PyPI on: push diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 71df607d..f55ca7d4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -93,14 +93,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.12'] + python-version: ['3.8', '3.13'] django-version: - '3.2' # LTS April 2024 - '5.1' # December 2025 exclude: - python-version: '3.8' django-version: '5.1' - - python-version: '3.12' + - python-version: '3.13' django-version: '3.2' steps: @@ -150,14 +150,14 @@ jobs: runs-on: macos-latest strategy: matrix: - python-version: ['3.8', '3.12'] + python-version: ['3.8', '3.13'] django-version: - '3.2' # LTS April 2024 - '5.1' # December 2025 exclude: - python-version: '3.8' django-version: '5.1' - - python-version: '3.12' + - python-version: '3.13' django-version: '3.2' steps: @@ -234,14 +234,14 @@ jobs: # runs-on: windows-latest # strategy: # matrix: - # python-version: ['3.8', '3.12'] + # python-version: ['3.8', '3.13'] # django-version: # - '3.2' # LTS April 2024 # - '5.0' # April 2025 # exclude: # - python-version: '3.8' # django-version: '5.0' - # - python-version: '3.12' + # - python-version: '3.13' # django-version: '3.2' # steps: @@ -294,9 +294,6 @@ jobs: timeout-minutes: 60 - uses: actions/checkout@v4 - uses: actions/setup-python@v5 - with: - python-version: '3.12' - - name: Install Poetry uses: snok/install-poetry@v1 with: From a160ca64c4303d028bfe65546508c6d3d25f38ed Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Tue, 8 Oct 2024 23:29:43 -0700 Subject: [PATCH 19/36] try make help checks more strict --- tests/apps/test_app/helps/multiply.txt | 2 +- tests/apps/test_app2/helps/multiply.txt | 2 +- tests/test_groups.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/apps/test_app/helps/multiply.txt b/tests/apps/test_app/helps/multiply.txt index 30c3a515..8b4455d5 100644 --- a/tests/apps/test_app/helps/multiply.txt +++ b/tests/apps/test_app/helps/multiply.txt @@ -6,7 +6,7 @@ ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ * number1 FLOAT The first number. [required] │ │ * number2 FLOAT The second number. [required] │ -│ * numbers NUMBERS... The list of numbers to multiple: │ +│ * numbers NUMBERS... The list of numbers to multiply: │ │ n1*n2*n3*...*nN. │ │ [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/apps/test_app2/helps/multiply.txt b/tests/apps/test_app2/helps/multiply.txt index 30c3a515..8b4455d5 100644 --- a/tests/apps/test_app2/helps/multiply.txt +++ b/tests/apps/test_app2/helps/multiply.txt @@ -6,7 +6,7 @@ ╭─ Arguments ──────────────────────────────────────────────────────────────────╮ │ * number1 FLOAT The first number. [required] │ │ * number2 FLOAT The second number. [required] │ -│ * numbers NUMBERS... The list of numbers to multiple: │ +│ * numbers NUMBERS... The list of numbers to multiply: │ │ n1*n2*n3*...*nN. │ │ [required] │ ╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/tests/test_groups.py b/tests/test_groups.py index 52fc4415..d8914929 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -73,7 +73,7 @@ def test_get_help_from_incongruent_path(self): TESTS_DIR / "apps" / "test_app" / "helps" / "groups.txt" ).read_text(), ), - 0.96, # width inconsistences drive this number < 1 + 0.99, # width inconsistences drive this number < 1 ) return finally: @@ -127,7 +127,7 @@ def test_helps(self, app="test_app"): TESTS_DIR / "apps" / app / helps_dir / f"{cmds[-1]}.txt" ).read_text(), ), - 0.96, # width inconsistences drive this number < 1 + 0.99, # width inconsistences drive this number < 1 ) except AssertionError: raise From 0fdfe9b8840bbba3425e1a399a9f242a9a04dd4c Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 02:21:50 -0700 Subject: [PATCH 20/36] fix #129, progress towards #128 --- ARCHITECTURE.md | 2 +- django_typer/management/__init__.py | 46 ++++++--- tests/apps/howto/management/commands/order.py | 33 +++++++ .../howto/management/commands/order_typer.py | 43 +++++++++ .../test_app/management/commands/order.py | 39 ++++++++ .../test_app/management/commands/order2.py | 45 +++++++++ tests/test_basics.py | 96 +++++++++++++++++++ tests/test_howto.py | 20 ++++ tests/utils.py | 1 - 9 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 tests/apps/howto/management/commands/order.py create mode 100644 tests/apps/howto/management/commands/order_typer.py create mode 100644 tests/apps/test_app/management/commands/order.py create mode 100644 tests/apps/test_app/management/commands/order2.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c61782ee..db46f434 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -The principal design challenge of [django-typer](https://pypi.python.org/pypi/django-typer) is to manage the [Typer](https://typer.tiangolo.com/) app trees associated with each Django management command class and to keep these trees separate when classes are inherited and allow them to be edited directly when commands are extended through the plugin pattern. There are also incurred complexities with adding default django options where appropriate and supporting command callbacks as methods or static functions. Supporting dynamic command/group access through attributes on command instances also requires careful usage of advanced Python features. +The principal design challenge of [django-typer](https://pypi.python.org/pypi/django-typer) is to manage the [Typer](https://typer.tiangolo.com/) app trees associated with each Django management command class and to keep these trees separate when classes are inherited and allow them to be edited directly when commands are extended through the plugin pattern. There are also incurred complexities with adding default django options where appropriate and supporting command callbacks as methods or static functions. Supporting dynamic command/group access through attributes on command instances also requires careful usage of esoteric Python features. The [Typer](https://typer.tiangolo.com/) app tree defines the layers of groups and commands that define the CLI. Each [TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) maintains its own app tree defined by a root [Typer](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.management.Typer) node. When other classes inherit from a base command class, that app tree is copied and the new class can modify it without affecting the base class's tree. We extend [Typer](https://typer.tiangolo.com/)'s Typer type with our own [Typer](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.management.Typer) class that adds additional bookkeeping and attribute resolution features we need. diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index dd57d231..dfe164c5 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -612,6 +612,18 @@ def grp(self): ... """ + def list_commands(self, ctx: click.Context) -> t.List[str]: + """ + Do our best to list commands in definition order. + """ + cmds = list(self.commands.keys()) + ordered = [] + for defined in getattr(self.django_command, "_defined_order", []): + if defined in cmds: + ordered.append(defined) + cmds.remove(defined) + return ordered + cmds + # staticmethod objects are not picklable which causes problems with deepcopy # hence the following mishegoss @@ -1663,9 +1675,9 @@ def _add_common_initializer( """ if cmd.is_compound_command and not cmd.typer_app.registered_callback: cmd.typer_app.callback( - cls=type( + cls=type( # pyright: ignore[reportArgumentType] "_Initializer", - (DTGroup,), + (cmd.typer_app.info.cls or DTGroup,), { "django_command": cmd, "_callback_is_method": False, @@ -1966,10 +1978,16 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]: to_remove = [] to_register = [] local_handle = attrs.pop("handle", None) + defined_order = [] for cmd_cls, cls_attrs in [ *[(base, vars(base)) for base in command_bases()], (None, attrs), ]: + defined_order += [ + cmd + for cmd in getattr(cmd_cls, "_defined_order", []) + if cmd not in defined_order + ] for name, attr in list(cls_attrs.items()): if name == "_handle": continue @@ -1980,8 +1998,12 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]: assert name to_remove.append(name) _defined_groups[name] = attr + if cmd_cls is None and name not in defined_order: + defined_order.append(name) elif register := get_ctor(attr): to_register.append(register) + if cmd_cls is None and name not in defined_order: + defined_order.append(name) handle = getattr(cmd_cls, "_handle", handle) @@ -2002,6 +2024,7 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]: if handle: ctor = get_ctor(handle) + defined_order.insert(0, typer_app.info.name) if ctor: to_register.append( lambda cmd_cls: ctor( @@ -2019,6 +2042,7 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]: ) attrs = { + "_defined_order": defined_order, **attrs, "_handle": handle, "_to_register": to_register, @@ -2034,20 +2058,20 @@ def get_ctor(attr: t.Any) -> t.Optional[t.Callable[..., t.Any]]: return super().__new__(mcs, cls_name, bases, attrs) - def __init__(cls, cls_name, bases, attrs, **kwargs): + def __init__(self, cls_name, bases, attrs, **kwargs): """ This method is called after a new class is created. """ - cls = t.cast(t.Type["TyperCommand"], cls) - if getattr(cls, "typer_app", None): - cls.typer_app.django_command = cls - cls.typer_app.info.name = ( - cls.typer_app.info.name or cls.__module__.rsplit(".", maxsplit=1)[-1] + self = t.cast(t.Type["TyperCommand"], self) + if getattr(self, "typer_app", None): + self.typer_app.django_command = self + self.typer_app.info.name = ( + self.typer_app.info.name or self.__module__.rsplit(".", maxsplit=1)[-1] ) - for cmd in getattr(cls, "_to_register", []): - cmd(cls) + for cmd in getattr(self, "_to_register", []): + cmd(self) - _add_common_initializer(cls) + _add_common_initializer(self) super().__init__(cls_name, bases, attrs, **kwargs) diff --git a/tests/apps/howto/management/commands/order.py b/tests/apps/howto/management/commands/order.py new file mode 100644 index 00000000..6b5ede95 --- /dev/null +++ b/tests/apps/howto/management/commands/order.py @@ -0,0 +1,33 @@ +from django_typer.management import TyperCommand, DTGroup, command, group +from click import Context + + +class ReverseAlphaCommands(DTGroup): + def list_commands(self, ctx: Context) -> list[str]: + return list(sorted(self.commands.keys(), reverse=True)) + + +class Command(TyperCommand, cls=ReverseAlphaCommands): + @command() + def a(self): + print("a") + + @command() + def b(self): + print("b") + + @command() + def c(self): + print("c") + + @group(cls=ReverseAlphaCommands) + def d(self): + print("d") + + @d.command() + def e(self): + print("e") + + @d.command() + def f(self): + print("f") diff --git a/tests/apps/howto/management/commands/order_typer.py b/tests/apps/howto/management/commands/order_typer.py new file mode 100644 index 00000000..29febb44 --- /dev/null +++ b/tests/apps/howto/management/commands/order_typer.py @@ -0,0 +1,43 @@ +from django_typer.management import Typer, DTGroup +from click import Context + + +class ReverseAlphaCommands(DTGroup): + def list_commands(self, ctx: Context) -> list[str]: + return list(sorted(self.commands.keys(), reverse=True)) + + +app = Typer(cls=ReverseAlphaCommands) + +d_app = Typer(cls=ReverseAlphaCommands) +app.add_typer(d_app) + + +@app.command() +def a(): + print("a") + + +@app.command() +def b(): + print("b") + + +@app.command() +def c(): + print("c") + + +@d_app.callback(cls=ReverseAlphaCommands) +def d(): + print("d") + + +@d_app.command() +def e(): + print("e") + + +@d_app.command() +def f(): + print("f") diff --git a/tests/apps/test_app/management/commands/order.py b/tests/apps/test_app/management/commands/order.py new file mode 100644 index 00000000..6d353e94 --- /dev/null +++ b/tests/apps/test_app/management/commands/order.py @@ -0,0 +1,39 @@ +from django_typer.management import TyperCommand, command, group + + +class Command(TyperCommand): + """ + Test that helps list commands in definition order. + (This is different than click where the order is alphabetical by default) + """ + + @command() + def b(self): + print("b") + + @command() + def a(self): + print("a") + + @group() + def d(self): + print("d") + + @command() + def c(self): + print("c") + + @d.command() + def g(self): + print("g") + + def handle(self): + print("handle") + + @d.command() + def e(self): + print("e") + + @d.command() + def f(self): + print("f") diff --git a/tests/apps/test_app/management/commands/order2.py b/tests/apps/test_app/management/commands/order2.py new file mode 100644 index 00000000..def0d457 --- /dev/null +++ b/tests/apps/test_app/management/commands/order2.py @@ -0,0 +1,45 @@ +from django_typer.management import TyperCommand, command, group +from tests.apps.test_app.management.commands.order import Command as OrderCommand + + +class Command(OrderCommand): + """ + Test that helps list commands in definition order. + (This is different than click where the order is alphabetical by default) + """ + + @group() + def bb(self): + print("bb") + + @command() + def aa(self): + print("aa") + + @OrderCommand.d.group() + def x(self): + print("x") + + @command() + def b(self): + print("b") + + @OrderCommand.d.command() + def i(self): + print("i") + + @OrderCommand.d.command() + def h(self): + print("h") + + @command(help="Override handle") + def handle(self): + print("handle") + + @x.command() + def z(self): + print("z") + + @x.command() + def y(self): + print("y") diff --git a/tests/test_basics.py b/tests/test_basics.py index 077f57b0..f15e1c6f 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -125,3 +125,99 @@ def test_get_command_make_callable(self): self.assertEqual( get_command("base")(*args, **kwargs), f"base({args}, {kwargs})" ) + + def test_cmd_help_order(self): + buffer = StringIO() + cmd = get_command("order", TyperCommand, stdout=buffer, no_color=True) + + cmd.print_help("./manage.py", "order") + hlp = buffer.getvalue() + + self.assertTrue( + """ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ order │ +│ b │ +│ a │ +│ d │ +│ c │ +╰──────────────────────────────────────────────────────────────────────────────╯ + """.strip() + in hlp + ) + + buffer.seek(0) + buffer.truncate() + + cmd.print_help("./manage.py", "order", "d") + hlp = buffer.getvalue() + + self.assertTrue( + """ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ g │ +│ e │ +│ f │ +╰──────────────────────────────────────────────────────────────────────────────╯ + """.strip() + in hlp + ) + + cmd2 = get_command("order2", TyperCommand, stdout=buffer, no_color=True) + + buffer.seek(0) + buffer.truncate() + + cmd2.print_help("./manage.py", "order2") + hlp = buffer.getvalue() + + self.assertTrue( + """ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ order2 Override handle │ +│ b │ +│ a │ +│ d │ +│ c │ +│ bb │ +│ aa │ +╰──────────────────────────────────────────────────────────────────────────────╯ + """.strip() + in hlp + ) + + buffer.seek(0) + buffer.truncate() + + cmd2.print_help("./manage.py", "order2", "d") + hlp = buffer.getvalue() + + self.assertTrue( + """ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ g │ +│ e │ +│ f │ +│ i │ +│ h │ +│ x │ +╰──────────────────────────────────────────────────────────────────────────────╯ + """.strip() + in hlp + ) + + buffer.seek(0) + buffer.truncate() + + cmd2.print_help("./manage.py", "order2", "d", "x") + hlp = buffer.getvalue() + + self.assertTrue( + """ +╭─ Commands ───────────────────────────────────────────────────────────────────╮ +│ z │ +│ y │ +╰──────────────────────────────────────────────────────────────────────────────╯ + """.strip() + in hlp + ) diff --git a/tests/test_howto.py b/tests/test_howto.py index 5b61a0c9..58781f85 100644 --- a/tests/test_howto.py +++ b/tests/test_howto.py @@ -307,3 +307,23 @@ def test_howto_printing(self): class TestPrintingTyperHowto(TestPrintingHowto): cmd = "printing_typer" + + +@override_settings(INSTALLED_APPS=["tests.apps.howto"]) +class TestOrderHowTo(TestCase): + cmd = "order" + + def test_howto_order(self): + from tests.apps.howto.management.commands.order import ( + Command as OrderCommand, + ReverseAlphaCommands, + ) + + stdout = StringIO() + order_cmd = get_command(self.cmd, OrderCommand, stdout=stdout, no_color=True) + + self.assertTrue(issubclass(order_cmd.typer_app.info.cls, ReverseAlphaCommands)) + self.assertTrue(issubclass(order_cmd.d.info.cls, ReverseAlphaCommands)) + + # import ipdb + # ipdb.set_trace() diff --git a/tests/utils.py b/tests/utils.py index ea642df4..abe6a786 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -7,7 +7,6 @@ from typing import Tuple import re import math -import re from collections import Counter import pexpect from django.core.management.color import no_style From 33e8e95d1b6ceb30cc4a815f61bd8fd960035966 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 02:22:01 -0700 Subject: [PATCH 21/36] update changelog --- doc/source/changelog.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index ebc58b37..949e9003 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -5,6 +5,8 @@ Change Log v2.2.3 (2024-10-xx) =================== +* Implemented `Command help order should respect definition order for class based commands. `_ +* Fixed `Overriding the command group class does not work. `_ * Completed `Add project to test PyPI `_ * Completed `Open up vulnerability reporting and add security policy. `_ * Completed `Move architecture in docs to ARCHITECTURE.md `_ From e78bd751b2d06986c1e73de0a3ef0c2bfe344376 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 14:58:54 -0700 Subject: [PATCH 22/36] drop python 3.8 - fix #130 --- .github/workflows/lint.yml | 6 ++-- .github/workflows/test.yml | 18 +++++------- README.md | 2 +- django_typer/management/__init__.py | 9 ++---- .../management/commands/shellcompletion.py | 24 ++++++---------- django_typer/types.py | 7 +---- doc/source/changelog.rst | 1 + doc/source/index.rst | 6 ++-- doc/source/refs.rst | 1 - doc/source/tutorial.rst | 6 ---- .../polls/management/commands/closepoll.py | 10 ++----- .../management/commands/closepoll_typer.py | 10 ++----- .../management/commands/poll_as_option.py | 10 ++----- pyproject.toml | 5 ++-- tests/apps/howto/management/commands/order.py | 3 +- .../test_app/management/commands/adapted2.py | 7 +---- .../management/commands/completion.py | 7 +---- .../management/commands/dj_params4.py | 7 +---- .../test_app/management/commands/error.py | 7 +---- .../test_app/management/commands/groups.py | 28 ++++++++----------- .../management/commands/model_fields.py | 6 +--- .../management/commands/overloaded.py | 6 +--- .../test_app/management/commands/override.py | 15 ++++------ .../test_app/management/commands/prompt.py | 16 ++++------- .../test_app2/management/commands/groups.py | 14 +++------- tests/apps/util/management/commands/graph.py | 18 ++++-------- tests/test_interface.py | 3 +- 27 files changed, 75 insertions(+), 177 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fb470f44..3a33429c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,13 +18,13 @@ jobs: strategy: matrix: # run static analysis on bleeding and trailing edges - python-version: [ '3.8', '3.10', '3.13' ] + python-version: [ '3.9', '3.10', '3.13' ] django-version: - '3.2' # LTS April 2024 - '4.2' # LTS April 2026 - '5.1' # December 2025 exclude: - - python-version: '3.8' + - python-version: '3.9' django-version: '4.2' - python-version: '3.13' django-version: '4.2' @@ -32,7 +32,7 @@ jobs: django-version: '3.2' - python-version: '3.10' django-version: '3.2' - - python-version: '3.8' + - python-version: '3.9' django-version: '5.1' - python-version: '3.10' django-version: '5.1' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f55ca7d4..07394cc8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,23 +19,19 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] django-version: - '3.2' # LTS April 2024 - '4.2' # LTS April 2026 - '5.0' # April 2025 - '5.1' # December 2025 exclude: - - python-version: '3.8' - django-version: '5.0' - python-version: '3.9' django-version: '5.0' - python-version: '3.11' django-version: '3.2' - python-version: '3.12' django-version: '3.2' - - python-version: '3.8' - django-version: '5.1' - python-version: '3.9' django-version: '5.1' - python-version: '3.13' @@ -93,12 +89,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.8', '3.13'] + python-version: ['3.9', '3.13'] django-version: - '3.2' # LTS April 2024 - '5.1' # December 2025 exclude: - - python-version: '3.8' + - python-version: '3.9' django-version: '5.1' - python-version: '3.13' django-version: '3.2' @@ -150,12 +146,12 @@ jobs: runs-on: macos-latest strategy: matrix: - python-version: ['3.8', '3.13'] + python-version: ['3.9', '3.13'] django-version: - '3.2' # LTS April 2024 - '5.1' # December 2025 exclude: - - python-version: '3.8' + - python-version: '3.9' django-version: '5.1' - python-version: '3.13' django-version: '3.2' @@ -234,12 +230,12 @@ jobs: # runs-on: windows-latest # strategy: # matrix: - # python-version: ['3.8', '3.13'] + # python-version: ['3.9', '3.13'] # django-version: # - '3.2' # LTS April 2024 # - '5.0' # April 2025 # exclude: - # - python-version: '3.8' + # - python-version: '3.9' # django-version: '5.0' # - python-version: '3.13' # django-version: '3.2' diff --git a/README.md b/README.md index b8a51a5e..ada0a929 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Please refer to the [full documentation](https://django-typer.readthedocs.io/) f ## Basic Example -[TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) is a very simple drop in replacement for [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand). All of the documented features of [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand) work the same way! +[TyperCommand](https://django-typer.readthedocs.io/en/latest/reference.html#django_typer.TyperCommand) is a drop in extension to [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand). All of the documented features of [BaseCommand](https://docs.djangoproject.com/en/stable/howto/custom-management-commands/#django.core.management.BaseCommand) work the same way! ```python from django_typer.management import TyperCommand diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index dfe164c5..758ec780 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -775,11 +775,6 @@ class Command( else: return Typer(**kwargs) - if sys.version_info < (3, 9): - # this is a workaround for a bug in python 3.8 that causes multiple - # values for cls error. 3.8 support is sun setting soon so we just punt - # on this one - REMOVE when 3.8 support is dropped - kwargs.pop("cls", None) return super().__call__(*args, **kwargs) @@ -1875,7 +1870,7 @@ def __new__( bases, attrs, name: t.Optional[str] = Default(None), - # cls: t.Optional[t.Type[DTGroup]] = DTGroup, + cls: t.Optional[t.Type[DTGroup]] = DTGroup, invoke_without_command: bool = Default(False), no_args_is_help: bool = Default(False), subcommand_metavar: t.Optional[str] = Default(None), @@ -1941,7 +1936,7 @@ def command_bases() -> t.Generator[t.Type[TyperCommand], None, None]: typer_app = Typer( name=name or attrs.get("__module__", "").rsplit(".", maxsplit=1)[-1], - cls=kwargs.pop("cls", DTGroup), + cls=cls, help=help or attr_help, # pyright: ignore[reportArgumentType] invoke_without_command=invoke_without_command, no_args_is_help=no_args_is_help, diff --git a/django_typer/management/commands/shellcompletion.py b/django_typer/management/commands/shellcompletion.py index e64303cb..611b2240 100644 --- a/django_typer/management/commands/shellcompletion.py +++ b/django_typer/management/commands/shellcompletion.py @@ -28,14 +28,8 @@ import os import sys import typing as t -from pathlib import Path - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from functools import cached_property +from pathlib import Path from click.parser import split_arg_string from click.shell_completion import ( @@ -270,7 +264,7 @@ def replace(s: str, old: str, new: str, occurrences: t.List[int]) -> str: ) def install( self, - shell: Annotated[ + shell: t.Annotated[ t.Optional[Shells], Argument( help=t.cast( @@ -278,7 +272,7 @@ def install( ) ), ] = DETECTED_SHELL, - manage_script: Annotated[ + manage_script: t.Annotated[ t.Optional[str], Option( help=t.cast( @@ -290,7 +284,7 @@ def install( ) ), ] = None, - fallback: Annotated[ + fallback: t.Annotated[ t.Optional[str], Option( help=t.cast( @@ -337,7 +331,7 @@ def install( ) def remove( self, - shell: Annotated[ + shell: t.Annotated[ t.Optional[Shells], Argument( help=t.cast( @@ -346,7 +340,7 @@ def remove( ) ), ] = DETECTED_SHELL, - manage_script: Annotated[ + manage_script: t.Annotated[ t.Optional[str], Option( help=t.cast( @@ -444,7 +438,7 @@ def remove( ) def complete( self, - cmd_str: Annotated[ + cmd_str: t.Annotated[ t.Optional[str], Argument( metavar="command", @@ -453,7 +447,7 @@ def complete( ), ), ] = None, - shell: Annotated[ + shell: t.Annotated[ t.Optional[Shells], Option( help=t.cast( @@ -464,7 +458,7 @@ def complete( ) ), ] = None, - fallback: Annotated[ + fallback: t.Annotated[ t.Optional[str], Option( help=t.cast( diff --git a/django_typer/types.py b/django_typer/types.py index ea2d27da..b053023a 100644 --- a/django_typer/types.py +++ b/django_typer/types.py @@ -6,12 +6,7 @@ import sys from pathlib import Path -from typing import Optional, cast - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated, Optional, cast from django.core.management import CommandError from django.utils.translation import gettext_lazy as _ diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 949e9003..81c39f18 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -5,6 +5,7 @@ Change Log v2.2.3 (2024-10-xx) =================== +* Implemented `Drop python 3.8 support. `_ * Implemented `Command help order should respect definition order for class based commands. `_ * Fixed `Overriding the command group class does not work. `_ * Completed `Add project to test PyPI `_ diff --git a/doc/source/index.rst b/doc/source/index.rst index db27aca0..fa933806 100644 --- a/doc/source/index.rst +++ b/doc/source/index.rst @@ -82,9 +82,9 @@ developers will be familiar with. All of the BaseCommand_ functionality is prese :big:`Basic Example` -:class:`~django_typer.management.TyperCommand` is a very simple drop in replacement for -BaseCommand_. All of the documented features of BaseCommand_ work the same way! Or, you may also -use an interface identical to Typer_'s. Simply import Typer_ from django_typer instead of typer. +:class:`~django_typer.management.TyperCommand` is a drop in extension to BaseCommand_. All of the +documented features of BaseCommand_ work the same way! Or, you may also use an interface identical +to Typer_'s. Simply import Typer_ from django_typer instead of typer. .. tabs:: diff --git a/doc/source/refs.rst b/doc/source/refs.rst index 9cee1916..d43189ef 100644 --- a/doc/source/refs.rst +++ b/doc/source/refs.rst @@ -13,7 +13,6 @@ .. _fish: https://fishshell.com/ .. _zsh: https://www.zsh.org/ .. _bash: https://www.gnu.org/software/bash/ -.. _typing_extensions: https://pypi.org/project/typing-extensions/ .. _Arguments: https://typer.tiangolo.com/tutorial/arguments/ .. _Options: https://typer.tiangolo.com/tutorial/options/ .. _call_command: https://docs.djangoproject.com/en/5.0/ref/django-admin/#running-management-commands-from-your-code diff --git a/doc/source/tutorial.rst b/doc/source/tutorial.rst index da4181a8..51545f6d 100644 --- a/doc/source/tutorial.rst +++ b/doc/source/tutorial.rst @@ -181,12 +181,6 @@ markup the docstring may be the more appropriate place to put it. | -.. note:: - - On Python <=3.8 you will need to import Annotated from typing_extensions_ instead of the standard - library. - - Defining custom and reusable parameter types ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/examples/polls/management/commands/closepoll.py b/examples/polls/management/commands/closepoll.py index b8115588..224ec700 100644 --- a/examples/polls/management/commands/closepoll.py +++ b/examples/polls/management/commands/closepoll.py @@ -1,11 +1,5 @@ -import sys import typing as t -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from typer import Argument, Option from django_typer.management import TyperCommand, model_parser_completer @@ -17,13 +11,13 @@ class Command(TyperCommand): def handle( self, - polls: Annotated[ + polls: t.Annotated[ t.List[Poll], Argument( **model_parser_completer(Poll, help_field="question_text") ), ], - delete: Annotated[ + delete: t.Annotated[ bool, Option(help="Delete poll instead of closing it.") ] = False, ): diff --git a/examples/polls/management/commands/closepoll_typer.py b/examples/polls/management/commands/closepoll_typer.py index e488483e..6c9fe995 100644 --- a/examples/polls/management/commands/closepoll_typer.py +++ b/examples/polls/management/commands/closepoll_typer.py @@ -1,11 +1,5 @@ -import sys import typing as t -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from typer import Argument, Option from django_typer.management import Typer, model_parser_completer @@ -17,11 +11,11 @@ @app.command() def handle( self, - polls: Annotated[ + polls: t.Annotated[ t.List[Poll], Argument(**model_parser_completer(Poll, help_field="question_text")), ], - delete: Annotated[ + delete: t.Annotated[ bool, Option(help="Delete poll instead of closing it.") ] = False, ): diff --git a/examples/polls/management/commands/poll_as_option.py b/examples/polls/management/commands/poll_as_option.py index f379ba8b..336d44c3 100644 --- a/examples/polls/management/commands/poll_as_option.py +++ b/examples/polls/management/commands/poll_as_option.py @@ -1,11 +1,5 @@ -import sys import typing as t -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from typer import Option from django_typer.management import TyperCommand, model_parser_completer @@ -17,14 +11,14 @@ class Command(TyperCommand): def handle( self, - polls: Annotated[ + polls: t.Annotated[ t.List[Poll], Option( **model_parser_completer(Poll, help_field="question_text"), metavar="POLL", ), ], - delete: Annotated[ + delete: t.Annotated[ bool, Option(help="Delete poll instead of closing it.") ] = False, ): diff --git a/pyproject.toml b/pyproject.toml index 5d457fd4..bf7d3c8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,6 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Natural Language :: English", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", @@ -57,7 +56,7 @@ classifiers = [ [tool.poetry.dependencies] -python = ">=3.8,<4.0" +python = ">=3.9,<4.0" Django = ">=3.2,<6.0" click = "^8.1.0" @@ -72,7 +71,7 @@ rich = { version = ">=10.11.0,<14.0.0", optional = true } shellingham = ">=1.5.4" -# we need this on 3.8 for Annotated types and 3.9 for ParamSpec +# we need this on 3.9 for ParamSpec typing-extensions = { version = ">=3.7.4.3", markers = "python_version < '3.10'" } diff --git a/tests/apps/howto/management/commands/order.py b/tests/apps/howto/management/commands/order.py index 6b5ede95..e6efa17e 100644 --- a/tests/apps/howto/management/commands/order.py +++ b/tests/apps/howto/management/commands/order.py @@ -1,9 +1,10 @@ +import typing as t from django_typer.management import TyperCommand, DTGroup, command, group from click import Context class ReverseAlphaCommands(DTGroup): - def list_commands(self, ctx: Context) -> list[str]: + def list_commands(self, ctx: Context) -> t.List[str]: return list(sorted(self.commands.keys(), reverse=True)) diff --git a/tests/apps/test_app/management/commands/adapted2.py b/tests/apps/test_app/management/commands/adapted2.py index 2d74f75c..bdccff5f 100644 --- a/tests/apps/test_app/management/commands/adapted2.py +++ b/tests/apps/test_app/management/commands/adapted2.py @@ -1,14 +1,9 @@ -import sys - from django_typer.management import group, initialize from django_typer import types from .interference import Command as Interference -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated import typer diff --git a/tests/apps/test_app/management/commands/completion.py b/tests/apps/test_app/management/commands/completion.py index e9214a73..23258b3d 100644 --- a/tests/apps/test_app/management/commands/completion.py +++ b/tests/apps/test_app/management/commands/completion.py @@ -1,12 +1,7 @@ import json -import sys import typing as t from pathlib import Path - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated import typer from django.apps import AppConfig diff --git a/tests/apps/test_app/management/commands/dj_params4.py b/tests/apps/test_app/management/commands/dj_params4.py index fa817ab3..b769eb58 100644 --- a/tests/apps/test_app/management/commands/dj_params4.py +++ b/tests/apps/test_app/management/commands/dj_params4.py @@ -1,9 +1,4 @@ -import sys - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated from django.core.management import CommandError from typer import Option diff --git a/tests/apps/test_app/management/commands/error.py b/tests/apps/test_app/management/commands/error.py index e55e2787..10a52295 100644 --- a/tests/apps/test_app/management/commands/error.py +++ b/tests/apps/test_app/management/commands/error.py @@ -1,9 +1,4 @@ -import sys - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated from typer import Option diff --git a/tests/apps/test_app/management/commands/groups.py b/tests/apps/test_app/management/commands/groups.py index dba1654e..32e0b2bd 100644 --- a/tests/apps/test_app/management/commands/groups.py +++ b/tests/apps/test_app/management/commands/groups.py @@ -1,11 +1,5 @@ -import sys import typing as t -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from django.utils.translation import gettext_lazy as _ from typer import Argument, Option @@ -37,13 +31,13 @@ def math(self, precision: int = precision): @math.command() def multiply( self, - number1: Annotated[ + number1: t.Annotated[ float, Argument(help=_("The first number."), show_default=False) ], - number2: Annotated[ + number2: t.Annotated[ float, Argument(help=_("The second number."), show_default=False) ], - numbers: Annotated[ + numbers: t.Annotated[ t.List[float], Argument( help=_("The list of numbers to multiply: n1*n2*n3*...*nN. "), @@ -63,13 +57,13 @@ def multiply( @math.command() def divide( self, - number1: Annotated[ + number1: t.Annotated[ float, Argument(help=_("The numerator."), show_default=False) ], - number2: Annotated[ + number2: t.Annotated[ float, Argument(help=_("The denominator."), show_default=False) ], - numbers: Annotated[ + numbers: t.Annotated[ t.List[float], Argument( help=_("Additional denominators: n1/n2/n3/.../nN."), show_default=False @@ -88,7 +82,7 @@ def divide( @group() def string( self, - string: Annotated[ + string: t.Annotated[ str, Argument(help=_("The string to operate on."), show_default=False) ], ): @@ -108,10 +102,10 @@ def case(self): @case.command() def upper( self, - begin: Annotated[ + begin: t.Annotated[ int, Argument(help=_("The starting index of the string to operate on.")) ] = 0, - end: Annotated[ + end: t.Annotated[ t.Optional[int], Argument(help=_("The ending index of the string to operate on.")), ] = None, @@ -125,10 +119,10 @@ def upper( @case.command() def lower( self, - begin: Annotated[ + begin: t.Annotated[ int, Option(help=_("The starting index of the string to operate on.")) ] = 0, - end: Annotated[ + end: t.Annotated[ t.Optional[int], Option(help=_("The ending index of the string to operate on.")), ] = None, diff --git a/tests/apps/test_app/management/commands/model_fields.py b/tests/apps/test_app/management/commands/model_fields.py index 12c2daf2..3b40eb2f 100644 --- a/tests/apps/test_app/management/commands/model_fields.py +++ b/tests/apps/test_app/management/commands/model_fields.py @@ -1,11 +1,7 @@ import json import sys import typing as t - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated import typer diff --git a/tests/apps/test_app/management/commands/overloaded.py b/tests/apps/test_app/management/commands/overloaded.py index ffb327a0..4c7a083f 100644 --- a/tests/apps/test_app/management/commands/overloaded.py +++ b/tests/apps/test_app/management/commands/overloaded.py @@ -1,10 +1,6 @@ import json import sys - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated +from typing import Annotated from django.utils.translation import gettext_lazy as _ from typer import Argument, Option diff --git a/tests/apps/test_app/management/commands/override.py b/tests/apps/test_app/management/commands/override.py index fff843f3..394aaca9 100644 --- a/tests/apps/test_app/management/commands/override.py +++ b/tests/apps/test_app/management/commands/override.py @@ -1,12 +1,5 @@ -import sys - from django_typer.management import TyperCommand -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - import typing as t from enum import Enum from pathlib import Path @@ -25,9 +18,11 @@ class Command(TyperCommand): def handle( self, - settings: Annotated[Path, Argument(help="Override default settings argument.")], - optional_arg: Annotated[int, Argument(help="An optional argument.")] = 0, - version: Annotated[ + settings: t.Annotated[ + Path, Argument(help="Override default settings argument.") + ], + optional_arg: t.Annotated[int, Argument(help="An optional argument.")] = 0, + version: t.Annotated[ t.Optional[VersionEnum], Option("--version", help="Override default version argument."), ] = None, diff --git a/tests/apps/test_app/management/commands/prompt.py b/tests/apps/test_app/management/commands/prompt.py index 198580a9..d13584d2 100644 --- a/tests/apps/test_app/management/commands/prompt.py +++ b/tests/apps/test_app/management/commands/prompt.py @@ -1,11 +1,5 @@ -import sys import typing as t -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from django.utils.translation import gettext_lazy as _ from typer import Option @@ -19,7 +13,7 @@ class Command(TyperCommand): def cmd1( self, username: str, - password: Annotated[ + password: t.Annotated[ t.Optional[str], Option("-p", hide_input=True, prompt=True) ] = None, ): @@ -30,7 +24,7 @@ def cmd1( def cmd2( self, username: str, - password: Annotated[ + password: t.Annotated[ t.Optional[str], Option("-p", hide_input=True, prompt=True, prompt_required=False), ] = None, @@ -42,7 +36,7 @@ def cmd2( def cmd3( self, username: str, - password: Annotated[ + password: t.Annotated[ str, Option("-p", hide_input=True, prompt=True, prompt_required=False) ] = "default", ): @@ -52,7 +46,7 @@ def cmd3( @group() def group1( self, - flag: Annotated[ + flag: t.Annotated[ str, Option("-f", hide_input=True, prompt=True, prompt_required=True) ], ): @@ -63,7 +57,7 @@ def group1( def cmd4( self, username: str, - password: Annotated[str, Option("-p", hide_input=True, prompt=True)], + password: t.Annotated[str, Option("-p", hide_input=True, prompt=True)], ): assert self.__class__ is Command return f"{self.flag} {username} {password}" diff --git a/tests/apps/test_app2/management/commands/groups.py b/tests/apps/test_app2/management/commands/groups.py index 85e7b8db..b1dcdee0 100644 --- a/tests/apps/test_app2/management/commands/groups.py +++ b/tests/apps/test_app2/management/commands/groups.py @@ -1,13 +1,7 @@ -import sys - -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from django.conf import settings from django.utils.translation import gettext_lazy as _ from typer import Argument, Option +import typing as t from django_typer.management import command, group, initialize from django_typer import types @@ -38,7 +32,7 @@ def init(self, verbosity: types.Verbosity = verbosity): def echo( self, message: str, - echoes: Annotated[ + echoes: t.Annotated[ int, Argument(help="Number of times to echo the message.") ] = 1, ): @@ -62,7 +56,7 @@ def strip(self): @group() def setting( - self, setting: Annotated[str, Argument(help=_("The setting variable name."))] + self, setting: t.Annotated[str, Argument(help=_("The setting variable name."))] ): """ Get or set Django settings. @@ -73,7 +67,7 @@ def setting( @setting.command() def print( self, - safe: Annotated[bool, Option(help=_("Do not assume the setting exists."))], + safe: t.Annotated[bool, Option(help=_("Do not assume the setting exists."))], ): """ Print the setting value. diff --git a/tests/apps/util/management/commands/graph.py b/tests/apps/util/management/commands/graph.py index 8bd82e97..989a04af 100644 --- a/tests/apps/util/management/commands/graph.py +++ b/tests/apps/util/management/commands/graph.py @@ -1,6 +1,5 @@ import importlib import inspect -import sys import typing as t import typer.core @@ -10,11 +9,6 @@ from django_typer.management import Typer, TyperCommand, get_command from django_typer import completers, parsers, utils -if sys.version_info < (3, 9): - from typing_extensions import Annotated -else: - from typing import Annotated - from pathlib import Path import graphviz @@ -59,7 +53,7 @@ class Command(TyperCommand): def handle( self, - commands: Annotated[ + commands: t.Annotated[ t.List[str], typer.Argument( help="Import path(s) to the command to graph, or simply the name of the command.", @@ -70,7 +64,7 @@ def handle( ), ), ], - output: Annotated[ + output: t.Annotated[ Path, typer.Option( "-o", @@ -79,7 +73,7 @@ def handle( shell_complete=completers.complete_path, ), ] = Path("{command}_app_tree"), - format: Annotated[ + format: t.Annotated[ Format, typer.Option( "-f", @@ -88,14 +82,14 @@ def handle( shell_complete=completers.these_strings(list(Format)), ), ] = Format.png, - show_ids: Annotated[ + show_ids: t.Annotated[ bool, typer.Option(help="Show the object identifiers.") ] = show_ids, - instantiate: Annotated[ + instantiate: t.Annotated[ bool, typer.Option(help="Instantiate the command before graphing the app tree."), ] = True, - load_order: Annotated[ + load_order: t.Annotated[ t.List[AppConfig], typer.Option( "-l", diff --git a/tests/test_interface.py b/tests/test_interface.py index bb77420a..53d11089 100644 --- a/tests/test_interface.py +++ b/tests/test_interface.py @@ -28,7 +28,7 @@ def compare_defaults( if "cls" in params: params.remove( "cls" - ) # cls default is always purposefully overriden in django-typer interfaces + ) # cls default is always purposefully overridden in django-typer interfaces # sanity self.assertGreater(len(params), 0) dt_defaults = get_named_defaults(dt_function) @@ -149,7 +149,6 @@ def test_typercommandmeta_interface_matches(self): typer_command_params = set(get_named_arguments(TyperCommandMeta.__new__)) typer_params = set(get_named_arguments(typer.Typer.__init__)) typer_params.remove("add_completion") - typer_params.remove("cls") self.assertFalse(typer_command_params.symmetric_difference(typer_params)) typer_command_params.remove("pretty_exceptions_enable") From 70c9c693076cb67ca74439231136837f11f1caf9 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 15:21:17 -0700 Subject: [PATCH 23/36] fix no rich test for #129 --- tests/test_basics.py | 131 ++++++++++++++++++++++++------------------- 1 file changed, 72 insertions(+), 59 deletions(-) diff --git a/tests/test_basics.py b/tests/test_basics.py index f15e1c6f..8eb00641 100644 --- a/tests/test_basics.py +++ b/tests/test_basics.py @@ -6,7 +6,7 @@ from django.test import TestCase from django_typer.management import TyperCommand, get_command -from tests.utils import run_command +from tests.utils import run_command, rich_installed from django_typer.utils import get_current_command @@ -133,18 +133,23 @@ def test_cmd_help_order(self): cmd.print_help("./manage.py", "order") hlp = buffer.getvalue() - self.assertTrue( - """ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ order │ -│ b │ -│ a │ -│ d │ -│ c │ -╰──────────────────────────────────────────────────────────────────────────────╯ - """.strip() - in hlp - ) + if rich_installed: + self.assertTrue( + hlp.index("│ order") + < hlp.index("│ b") + < hlp.index("│ a") + < hlp.index("│ d") + < hlp.index("│ c") + ) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue( + hlp.index(" order", cmd_idx) + < hlp.index(" b", cmd_idx) + < hlp.index(" a", cmd_idx) + < hlp.index(" d", cmd_idx) + < hlp.index(" c", cmd_idx) + ) buffer.seek(0) buffer.truncate() @@ -152,16 +157,15 @@ def test_cmd_help_order(self): cmd.print_help("./manage.py", "order", "d") hlp = buffer.getvalue() - self.assertTrue( - """ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ g │ -│ e │ -│ f │ -╰──────────────────────────────────────────────────────────────────────────────╯ - """.strip() - in hlp - ) + if rich_installed: + self.assertTrue(hlp.index("│ g") < hlp.index("│ e") < hlp.index("│ f")) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue( + hlp.index(" g", cmd_idx) + < hlp.index(" e", cmd_idx) + < hlp.index(" f", cmd_idx) + ) cmd2 = get_command("order2", TyperCommand, stdout=buffer, no_color=True) @@ -171,20 +175,27 @@ def test_cmd_help_order(self): cmd2.print_help("./manage.py", "order2") hlp = buffer.getvalue() - self.assertTrue( - """ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ order2 Override handle │ -│ b │ -│ a │ -│ d │ -│ c │ -│ bb │ -│ aa │ -╰──────────────────────────────────────────────────────────────────────────────╯ - """.strip() - in hlp - ) + if rich_installed: + self.assertTrue( + hlp.index("│ order2") + < hlp.index("│ b") + < hlp.index("│ a ") + < hlp.index("│ d") + < hlp.index("│ c") + < hlp.index("│ bb") + < hlp.index("│ aa") + ) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue( + hlp.index(" order2", cmd_idx) + < hlp.index(" b", cmd_idx) + < hlp.index(" a", cmd_idx) + < hlp.index(" d", cmd_idx) + < hlp.index(" c", cmd_idx) + < hlp.index(" bb", cmd_idx) + < hlp.index(" aa", cmd_idx) + ) buffer.seek(0) buffer.truncate() @@ -192,19 +203,25 @@ def test_cmd_help_order(self): cmd2.print_help("./manage.py", "order2", "d") hlp = buffer.getvalue() - self.assertTrue( - """ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ g │ -│ e │ -│ f │ -│ i │ -│ h │ -│ x │ -╰──────────────────────────────────────────────────────────────────────────────╯ - """.strip() - in hlp - ) + if rich_installed: + self.assertTrue( + hlp.index("│ g") + < hlp.index("│ e") + < hlp.index("│ f") + < hlp.index("│ i") + < hlp.index("│ h") + < hlp.index("│ x") + ) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue( + hlp.index(" g", cmd_idx) + < hlp.index(" e", cmd_idx) + < hlp.index(" f", cmd_idx) + < hlp.index(" i", cmd_idx) + < hlp.index(" h", cmd_idx) + < hlp.index(" x", cmd_idx) + ) buffer.seek(0) buffer.truncate() @@ -212,12 +229,8 @@ def test_cmd_help_order(self): cmd2.print_help("./manage.py", "order2", "d", "x") hlp = buffer.getvalue() - self.assertTrue( - """ -╭─ Commands ───────────────────────────────────────────────────────────────────╮ -│ z │ -│ y │ -╰──────────────────────────────────────────────────────────────────────────────╯ - """.strip() - in hlp - ) + if rich_installed: + self.assertTrue(hlp.index("│ z") < hlp.index("│ y")) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue(hlp.index(" z", cmd_idx) < hlp.index(" y", cmd_idx)) From e41abc9a8134533696afb193cbfbe04cbfa70252 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 15:49:17 -0700 Subject: [PATCH 24/36] add configurable workflow dispatch to release - also only trigger on tag pushes prefixed with v --- .github/workflows/release.yml | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b8878879..87a4845c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,8 +5,28 @@ name: Publish to PyPI -on: push - +on: + push: + tags: + - 'v*' # only publish on version tags (e.g. v1.0.0) + workflow_dispatch: + inputs: + pypi: + description: 'Publish to PyPi' + required: true + default: false + type: boolean + github: + description: 'Publish a GitHub Release' + required: true + default: false + type: boolean + testpypi: + description: 'Publish to TestPyPi' + required: true + default: true + type: boolean + env: PYPI_URL: https://pypi.org/p/django-typer PYPI_TEST_URL: https://test.pypi.org/project/django-typer @@ -38,6 +58,8 @@ jobs: publish-to-pypi: name: >- Publish Python 🐍 distribution 📦 to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish on tag pushes + #if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.pypi == 'true') || github.event_name != 'workflow_dispatch' }} needs: - build runs-on: ubuntu-latest @@ -59,6 +81,7 @@ jobs: name: >- Sign the Python 🐍 distribution 📦 with Sigstore and upload them to GitHub Release + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.github == 'true') || github.event_name != 'workflow_dispatch' }} needs: - publish-to-pypi runs-on: ubuntu-latest @@ -100,6 +123,7 @@ jobs: publish-to-testpypi: name: Publish Python 🐍 distribution 📦 to TestPyPI + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.testpypi == 'true') || github.event_name != 'workflow_dispatch' }} needs: - build runs-on: ubuntu-latest From ef672015110a27be6d4e243407fd8401f5518a70 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 15:50:09 -0700 Subject: [PATCH 25/36] change release job name --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87a4845c..b91756b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ # https://github.com/django-commons/django-commons-playground/blob/main/.github/workflows/release.yml # -name: Publish to PyPI +name: Publish Release on: push: From 8d106fa01006c158a78cb4a086fcb6572be87cc1 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Thu, 10 Oct 2024 15:54:04 -0700 Subject: [PATCH 26/36] tweaks to release gh --- .github/workflows/release.yml | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b91756b6..9a553b76 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,8 +34,7 @@ env: jobs: build: - name: Build distribution 📦 - if: startsWith(github.ref, 'refs/tags/') # only publish on tag pushes + name: Build Package runs-on: ubuntu-latest steps: @@ -56,10 +55,8 @@ jobs: path: dist/ publish-to-pypi: - name: >- - Publish Python 🐍 distribution 📦 to PyPI - if: startsWith(github.ref, 'refs/tags/') # only publish on tag pushes - #if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.pypi == 'true') || github.event_name != 'workflow_dispatch' }} + name: Publish to PyPI + if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.pypi == 'true') || github.event_name != 'workflow_dispatch' }} needs: - build runs-on: ubuntu-latest @@ -78,9 +75,7 @@ jobs: uses: pypa/gh-action-pypi-publish@release/v1.10 github-release: - name: >- - Sign the Python 🐍 distribution 📦 with Sigstore - and upload them to GitHub Release + name: Publish GitHub Release if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.github == 'true') || github.event_name != 'workflow_dispatch' }} needs: - publish-to-pypi @@ -122,7 +117,7 @@ jobs: --repo '${{ github.repository }}' publish-to-testpypi: - name: Publish Python 🐍 distribution 📦 to TestPyPI + name: Publish to TestPyPI if: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.testpypi == 'true') || github.event_name != 'workflow_dispatch' }} needs: - build From 28c212e4ef8cf1d480943e2c094c6315c1018b45 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Fri, 11 Oct 2024 10:34:11 -0700 Subject: [PATCH 27/36] update workflows --- .github/workflows/lint.yml | 2 +- .github/workflows/release.yml | 12 +++++++++++- .github/workflows/test.yml | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3a33429c..61acabfb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,4 +1,4 @@ -name: lint +name: Lint on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9a553b76..8002f264 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,7 +104,7 @@ jobs: gh release create '${{ github.ref_name }}' --repo '${{ github.repository }}' - --notes "" + --generate-notes - name: Upload artifact signatures to GitHub Release env: GITHUB_TOKEN: ${{ github.token }} @@ -141,3 +141,13 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ skip-existing: true + + notify-django-packages: + name: Notify Django Packages + runs-on: ubuntu-latest + needs: + - publish-to-pypi + steps: + - name: Notify Django Packages + run: + curl -X GET "https://djangopackages.org/packages/django-typer/fetch-data/" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 07394cc8..d67b3307 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,4 +1,4 @@ -name: test +name: Test on: push: From 260852feaf28028ddb00adac52ad97adba3af923 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Fri, 11 Oct 2024 12:02:34 -0700 Subject: [PATCH 28/36] fix #128 --- django_typer/management/__init__.py | 91 +++++++++++++++++++++-------- tests/test_howto.py | 57 ++++++++++++++---- 2 files changed, 114 insertions(+), 34 deletions(-) diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index 758ec780..251c7fcf 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -923,6 +923,7 @@ def __init__( if callback: self.name = callback.__name__ self.is_method = is_method(callback) + super().__init__( name=name, cls=type( @@ -1119,11 +1120,11 @@ def add_typer( # type: ignore typer_instance: "Typer", *, name: t.Optional[str] = Default(None), - cls: t.Type[DTGroup] = DTGroup, - invoke_without_command: bool = Default(False), - no_args_is_help: bool = Default(False), + cls: t.Type[DTGroup] = Default(DTGroup), + invoke_without_command: t.Union[bool, DefaultPlaceholder] = Default(False), + no_args_is_help: t.Union[bool, DefaultPlaceholder] = Default(False), subcommand_metavar: t.Optional[str] = Default(None), - chain: bool = Default(False), + chain: t.Union[bool, DefaultPlaceholder] = Default(False), result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), # Command context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), @@ -1132,9 +1133,9 @@ def add_typer( # type: ignore epilog: t.Optional[str] = Default(None), short_help: t.Optional[t.Union[str, Promise]] = Default(None), options_metavar: str = Default("[OPTIONS]"), - add_help_option: bool = Default(True), - hidden: bool = Default(False), - deprecated: bool = Default(False), + add_help_option: t.Union[bool, DefaultPlaceholder] = Default(True), + hidden: t.Union[bool, DefaultPlaceholder] = Default(False), + deprecated: t.Union[bool, DefaultPlaceholder] = Default(False), # Rich settings rich_help_panel: t.Union[str, None] = Default(None), **kwargs: t.Any, @@ -1142,25 +1143,67 @@ def add_typer( # type: ignore typer_instance.parent = self typer_instance.django_command = self.django_command + # there is some disconnect between how typer resolves these parameters when used + # natively and how they're resolved when used in the django-typer interface. The + # typer interface uses the info object as a fallback for default parameters without + # manually doing the check in add_typer, but we have to do it here to make this work + # with the django-typer interface. Not sure why. return super().add_typer( typer_instance=typer_instance, - name=name, - cls=type("_DTGroup", (cls,), {"django_command": self.django_command}), - invoke_without_command=invoke_without_command, - no_args_is_help=no_args_is_help, - subcommand_metavar=subcommand_metavar, - chain=chain, - result_callback=result_callback, - context_settings=context_settings, - callback=_strip_static(callback), - help=t.cast(str, help), - epilog=epilog, - short_help=t.cast(str, short_help), - options_metavar=options_metavar, - add_help_option=add_help_option, - hidden=hidden, - deprecated=deprecated, - rich_help_panel=rich_help_panel, + name=name + if not isinstance(name, DefaultPlaceholder) + else typer_instance.info.name, + cls=type("_DTGroup", (cls,), {"django_command": self.django_command}) + if not isinstance(cls, DefaultPlaceholder) + else typer_instance.info.cls, + invoke_without_command=invoke_without_command + if not isinstance(invoke_without_command, DefaultPlaceholder) + else typer_instance.info.invoke_without_command, + no_args_is_help=no_args_is_help + if not isinstance(no_args_is_help, DefaultPlaceholder) + else typer_instance.info.no_args_is_help, + subcommand_metavar=subcommand_metavar + if not isinstance(subcommand_metavar, DefaultPlaceholder) + else typer_instance.info.subcommand_metavar, + chain=chain + if not isinstance(chain, DefaultPlaceholder) + else typer_instance.info.chain, + result_callback=result_callback + if not isinstance(result_callback, DefaultPlaceholder) + else typer_instance.info.result_callback, + context_settings=context_settings + if not isinstance(context_settings, DefaultPlaceholder) + else typer_instance.info.context_settings, + callback=_strip_static(callback) + if not isinstance(callback, DefaultPlaceholder) + else typer_instance.info.callback, + help=t.cast(str, help) + if not isinstance(help, DefaultPlaceholder) + else typer_instance.info.help, + epilog=epilog + if not isinstance(epilog, DefaultPlaceholder) + else typer_instance.info.epilog, + short_help=t.cast( + str, + short_help + if not isinstance(short_help, DefaultPlaceholder) + else typer_instance.info.short_help, + ), + options_metavar=options_metavar + if not isinstance(options_metavar, DefaultPlaceholder) + else typer_instance.info.options_metavar, + add_help_option=add_help_option + if not isinstance(add_help_option, DefaultPlaceholder) + else typer_instance.info.add_help_option, + hidden=hidden + if not isinstance(hidden, DefaultPlaceholder) + else typer_instance.info.hidden, + deprecated=deprecated + if not isinstance(deprecated, DefaultPlaceholder) + else typer_instance.info.deprecated, + rich_help_panel=rich_help_panel + if not isinstance(rich_help_panel, DefaultPlaceholder) + else typer_instance.info.rich_help_panel, **kwargs, ) diff --git a/tests/test_howto.py b/tests/test_howto.py index 58781f85..2c374c1e 100644 --- a/tests/test_howto.py +++ b/tests/test_howto.py @@ -313,17 +313,54 @@ class TestPrintingTyperHowto(TestPrintingHowto): class TestOrderHowTo(TestCase): cmd = "order" + from tests.apps.howto.management.commands.order import ReverseAlphaCommands + + grp_cls = ReverseAlphaCommands + def test_howto_order(self): - from tests.apps.howto.management.commands.order import ( - Command as OrderCommand, - ReverseAlphaCommands, - ) + from tests.apps.howto.management.commands.order import Command as OrderCommand - stdout = StringIO() - order_cmd = get_command(self.cmd, OrderCommand, stdout=stdout, no_color=True) + buffer = StringIO() + order_cmd = get_command(self.cmd, OrderCommand, stdout=buffer, no_color=True) + + self.assertTrue(issubclass(order_cmd.typer_app.info.cls, self.grp_cls)) + self.assertTrue(issubclass(order_cmd.d.info.cls, self.grp_cls)) + + order_cmd.print_help("./manage.py", self.cmd) + hlp = buffer.getvalue() + + if rich_installed: + self.assertTrue( + hlp.index("│ d") + < hlp.index("│ c") + < hlp.index("│ b") + < hlp.index("│ a") + ) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue( + hlp.index(" d", cmd_idx) + < hlp.index(" c", cmd_idx) + < hlp.index(" b", cmd_idx) + < hlp.index(" a", cmd_idx) + ) + + buffer.seek(0) + buffer.truncate() + + order_cmd.print_help("./manage.py", self.cmd, "d") + hlp = buffer.getvalue() + + if rich_installed: + self.assertTrue(hlp.index("│ f") < hlp.index("│ e")) + else: + cmd_idx = hlp.index("Commands") + self.assertTrue(hlp.index(" f", cmd_idx) < hlp.index(" e", cmd_idx)) + + +class TestPrintingTyperHowto(TestOrderHowTo): + cmd = "order_typer" - self.assertTrue(issubclass(order_cmd.typer_app.info.cls, ReverseAlphaCommands)) - self.assertTrue(issubclass(order_cmd.d.info.cls, ReverseAlphaCommands)) + from tests.apps.howto.management.commands.order_typer import ReverseAlphaCommands - # import ipdb - # ipdb.set_trace() + grp_cls = ReverseAlphaCommands From 7ae55190e82338bb3f035b6417890e92a9791660 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Fri, 11 Oct 2024 15:13:42 -0700 Subject: [PATCH 29/36] tweak fix to #128, does not need to include other add_typer kwargs --- django_typer/management/__init__.py | 100 ++++++++++------------------ 1 file changed, 35 insertions(+), 65 deletions(-) diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index 251c7fcf..fcb9bae5 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -1121,10 +1121,10 @@ def add_typer( # type: ignore *, name: t.Optional[str] = Default(None), cls: t.Type[DTGroup] = Default(DTGroup), - invoke_without_command: t.Union[bool, DefaultPlaceholder] = Default(False), - no_args_is_help: t.Union[bool, DefaultPlaceholder] = Default(False), + invoke_without_command: bool = Default(False), + no_args_is_help: bool = Default(False), subcommand_metavar: t.Optional[str] = Default(None), - chain: t.Union[bool, DefaultPlaceholder] = Default(False), + chain: bool = Default(False), result_callback: t.Optional[t.Callable[..., t.Any]] = Default(None), # Command context_settings: t.Optional[t.Dict[t.Any, t.Any]] = Default(None), @@ -1133,9 +1133,9 @@ def add_typer( # type: ignore epilog: t.Optional[str] = Default(None), short_help: t.Optional[t.Union[str, Promise]] = Default(None), options_metavar: str = Default("[OPTIONS]"), - add_help_option: t.Union[bool, DefaultPlaceholder] = Default(True), - hidden: t.Union[bool, DefaultPlaceholder] = Default(False), - deprecated: t.Union[bool, DefaultPlaceholder] = Default(False), + add_help_option: bool = Default(True), + hidden: bool = Default(False), + deprecated: bool = Default(False), # Rich settings rich_help_panel: t.Union[str, None] = Default(None), **kwargs: t.Any, @@ -1143,67 +1143,37 @@ def add_typer( # type: ignore typer_instance.parent = self typer_instance.django_command = self.django_command - # there is some disconnect between how typer resolves these parameters when used - # natively and how they're resolved when used in the django-typer interface. The - # typer interface uses the info object as a fallback for default parameters without - # manually doing the check in add_typer, but we have to do it here to make this work - # with the django-typer interface. Not sure why. + assert cls # cls must be interface compatible with DTGroup + + group_class = ( + type( + "_DTGroup", + (cls,), + {"django_command": self.django_command}, + ) + if not isinstance(cls, DefaultPlaceholder) + else typer_instance.info.cls + ) + return super().add_typer( typer_instance=typer_instance, - name=name - if not isinstance(name, DefaultPlaceholder) - else typer_instance.info.name, - cls=type("_DTGroup", (cls,), {"django_command": self.django_command}) - if not isinstance(cls, DefaultPlaceholder) - else typer_instance.info.cls, - invoke_without_command=invoke_without_command - if not isinstance(invoke_without_command, DefaultPlaceholder) - else typer_instance.info.invoke_without_command, - no_args_is_help=no_args_is_help - if not isinstance(no_args_is_help, DefaultPlaceholder) - else typer_instance.info.no_args_is_help, - subcommand_metavar=subcommand_metavar - if not isinstance(subcommand_metavar, DefaultPlaceholder) - else typer_instance.info.subcommand_metavar, - chain=chain - if not isinstance(chain, DefaultPlaceholder) - else typer_instance.info.chain, - result_callback=result_callback - if not isinstance(result_callback, DefaultPlaceholder) - else typer_instance.info.result_callback, - context_settings=context_settings - if not isinstance(context_settings, DefaultPlaceholder) - else typer_instance.info.context_settings, - callback=_strip_static(callback) - if not isinstance(callback, DefaultPlaceholder) - else typer_instance.info.callback, - help=t.cast(str, help) - if not isinstance(help, DefaultPlaceholder) - else typer_instance.info.help, - epilog=epilog - if not isinstance(epilog, DefaultPlaceholder) - else typer_instance.info.epilog, - short_help=t.cast( - str, - short_help - if not isinstance(short_help, DefaultPlaceholder) - else typer_instance.info.short_help, - ), - options_metavar=options_metavar - if not isinstance(options_metavar, DefaultPlaceholder) - else typer_instance.info.options_metavar, - add_help_option=add_help_option - if not isinstance(add_help_option, DefaultPlaceholder) - else typer_instance.info.add_help_option, - hidden=hidden - if not isinstance(hidden, DefaultPlaceholder) - else typer_instance.info.hidden, - deprecated=deprecated - if not isinstance(deprecated, DefaultPlaceholder) - else typer_instance.info.deprecated, - rich_help_panel=rich_help_panel - if not isinstance(rich_help_panel, DefaultPlaceholder) - else typer_instance.info.rich_help_panel, + name=name, + cls=group_class, + invoke_without_command=invoke_without_command, + no_args_is_help=no_args_is_help, + subcommand_metavar=subcommand_metavar, + chain=chain, + result_callback=result_callback, + context_settings=context_settings, + callback=_strip_static(callback), + help=t.cast(str, help), + epilog=epilog, + short_help=t.cast(str, short_help), + options_metavar=options_metavar, + add_help_option=add_help_option, + hidden=hidden, + deprecated=deprecated, + rich_help_panel=rich_help_panel, **kwargs, ) From ec48f7936debeac349b4c2aa9edb51b5ad59292c Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Fri, 11 Oct 2024 16:26:44 -0700 Subject: [PATCH 30/36] add howto docs for command list order in helps fix #116 --- django_typer/management/__init__.py | 8 +++++ doc/source/changelog.rst | 1 + doc/source/howto.rst | 36 +++++++++++++++++++ tests/apps/howto/management/commands/order.py | 26 +++++++------- .../management/commands/order_default.py | 5 +++ .../howto/management/commands/order_typer.py | 33 ++++++++--------- tests/test_howto.py | 24 ++++++------- 7 files changed, 92 insertions(+), 41 deletions(-) create mode 100644 tests/apps/howto/management/commands/order_default.py diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index fcb9bae5..88afa9dc 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -588,6 +588,10 @@ class Command(TyperCommand): @command(cls=CustomCommand) def handle(self): ... + + + See `click commands api `_ + for more information. """ @@ -610,6 +614,10 @@ class Command(TyperCommand): @group(cls=CustomGroup) def grp(self): ... + + See `click docs on custom groups `_ + and `advanced patterns `_ for more + information. """ def list_commands(self, ctx: click.Context) -> t.List[str]: diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 81c39f18..60795277 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -12,6 +12,7 @@ v2.2.3 (2024-10-xx) * Completed `Open up vulnerability reporting and add security policy. `_ * Completed `Move architecture in docs to ARCHITECTURE.md `_ * Completed `Transfer to django-commons `_ +* Completed `Add howto for how to change the display order of commands in help. `_ v2.2.2 (2024-08-25) ==================== diff --git a/doc/source/howto.rst b/doc/source/howto.rst index 687aaa2a..9c6ed9c6 100644 --- a/doc/source/howto.rst +++ b/doc/source/howto.rst @@ -650,7 +650,43 @@ value: Docstring will be used as help. """ +Order Commands in Help Text +--------------------------- + +**By default commands are listed in the order they appear in the class**. You can override +this by +`using a custom click group `_. + +For example, to change the order of commands to be in reverse alphabetical order you could define +a custom group and override the ``list_commands`` method. Custom group and command classes may be +provided like below, but they must extend from django-typer's classes: + +* For groups: :class:`~django_typer.management.DTGroup` +* For commands: :class:`~django_typer.management.DTCommand` + +.. tabs:: + + .. tab:: Django-style + + .. literalinclude:: ../../tests/apps/howto/management/commands/order.py + + .. tab:: Typer-style + + .. literalinclude:: ../../tests/apps/howto/management/commands/order_typer.py + +.. tabs:: + + .. tab:: Default Order + + .. typer:: tests.apps.howto.management.commands.order_default.Command:typer_app + :prog: ./manage.py order + :width: 80 + + .. tab:: Alphabetized + .. typer:: tests.apps.howto.management.commands.order.Command:typer_app + :prog: ./manage.py order + :width: 80 Document Commands w/Sphinx -------------------------- diff --git a/tests/apps/howto/management/commands/order.py b/tests/apps/howto/management/commands/order.py index e6efa17e..1999e73a 100644 --- a/tests/apps/howto/management/commands/order.py +++ b/tests/apps/howto/management/commands/order.py @@ -3,32 +3,32 @@ from click import Context -class ReverseAlphaCommands(DTGroup): +class AlphabetizeCommands(DTGroup): def list_commands(self, ctx: Context) -> t.List[str]: - return list(sorted(self.commands.keys(), reverse=True)) + return list(sorted(self.commands.keys())) -class Command(TyperCommand, cls=ReverseAlphaCommands): - @command() - def a(self): - print("a") - +class Command(TyperCommand, cls=AlphabetizeCommands): @command() def b(self): print("b") @command() - def c(self): - print("c") + def a(self): + print("a") - @group(cls=ReverseAlphaCommands) + @group(cls=AlphabetizeCommands) def d(self): print("d") + @d.command() + def f(self): + print("f") + @d.command() def e(self): print("e") - @d.command() - def f(self): - print("f") + @command() + def c(self): + print("c") diff --git a/tests/apps/howto/management/commands/order_default.py b/tests/apps/howto/management/commands/order_default.py new file mode 100644 index 00000000..09318eba --- /dev/null +++ b/tests/apps/howto/management/commands/order_default.py @@ -0,0 +1,5 @@ +from .order import Command as Order + + +class Command(Order): + pass diff --git a/tests/apps/howto/management/commands/order_typer.py b/tests/apps/howto/management/commands/order_typer.py index 29febb44..9ebdc53a 100644 --- a/tests/apps/howto/management/commands/order_typer.py +++ b/tests/apps/howto/management/commands/order_typer.py @@ -1,43 +1,44 @@ from django_typer.management import Typer, DTGroup from click import Context +import typing as t -class ReverseAlphaCommands(DTGroup): - def list_commands(self, ctx: Context) -> list[str]: - return list(sorted(self.commands.keys(), reverse=True)) +class AlphabetizeCommands(DTGroup): + def list_commands(self, ctx: Context) -> t.List[str]: + return list(sorted(self.commands.keys())) -app = Typer(cls=ReverseAlphaCommands) +app = Typer(cls=AlphabetizeCommands) -d_app = Typer(cls=ReverseAlphaCommands) +d_app = Typer(cls=AlphabetizeCommands) app.add_typer(d_app) -@app.command() -def a(): - print("a") - - @app.command() def b(): print("b") @app.command() -def c(): - print("c") +def a(): + print("a") -@d_app.callback(cls=ReverseAlphaCommands) +@d_app.callback() def d(): print("d") +@d_app.command() +def f(): + print("f") + + @d_app.command() def e(): print("e") -@d_app.command() -def f(): - print("f") +@app.command() +def c(): + print("c") diff --git a/tests/test_howto.py b/tests/test_howto.py index 2c374c1e..ae620437 100644 --- a/tests/test_howto.py +++ b/tests/test_howto.py @@ -313,9 +313,9 @@ class TestPrintingTyperHowto(TestPrintingHowto): class TestOrderHowTo(TestCase): cmd = "order" - from tests.apps.howto.management.commands.order import ReverseAlphaCommands + from tests.apps.howto.management.commands.order import AlphabetizeCommands - grp_cls = ReverseAlphaCommands + grp_cls = AlphabetizeCommands def test_howto_order(self): from tests.apps.howto.management.commands.order import Command as OrderCommand @@ -331,18 +331,18 @@ def test_howto_order(self): if rich_installed: self.assertTrue( - hlp.index("│ d") - < hlp.index("│ c") + hlp.index("│ a") < hlp.index("│ b") - < hlp.index("│ a") + < hlp.index("│ c") + < hlp.index("│ d") ) else: cmd_idx = hlp.index("Commands") self.assertTrue( - hlp.index(" d", cmd_idx) - < hlp.index(" c", cmd_idx) + hlp.index(" a", cmd_idx) < hlp.index(" b", cmd_idx) - < hlp.index(" a", cmd_idx) + < hlp.index(" c", cmd_idx) + < hlp.index(" d", cmd_idx) ) buffer.seek(0) @@ -352,15 +352,15 @@ def test_howto_order(self): hlp = buffer.getvalue() if rich_installed: - self.assertTrue(hlp.index("│ f") < hlp.index("│ e")) + self.assertTrue(hlp.index("│ e") < hlp.index("│ f")) else: cmd_idx = hlp.index("Commands") - self.assertTrue(hlp.index(" f", cmd_idx) < hlp.index(" e", cmd_idx)) + self.assertTrue(hlp.index(" e", cmd_idx) < hlp.index(" f", cmd_idx)) class TestPrintingTyperHowto(TestOrderHowTo): cmd = "order_typer" - from tests.apps.howto.management.commands.order_typer import ReverseAlphaCommands + from tests.apps.howto.management.commands.order_typer import AlphabetizeCommands - grp_cls = ReverseAlphaCommands + grp_cls = AlphabetizeCommands From a2ff31acf3958dce8e4b50e613a114ada9ba8b76 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sat, 12 Oct 2024 18:23:53 -0700 Subject: [PATCH 31/36] fix #131 --- django_typer/management/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index 88afa9dc..8cebeb3b 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -1951,9 +1951,13 @@ def __new__( attr_help = base.help def command_bases() -> t.Generator[t.Type[TyperCommand], None, None]: - for base in reversed(bases): - if issubclass(base, TyperCommand) and base is not TyperCommand: - yield base + seen = set() + for first_level in reversed(bases): + for base in reversed(first_level.__mro__): + if issubclass(base, TyperCommand) and base is not TyperCommand: + if base not in seen: + seen.add(base) + yield base typer_app = Typer( name=name or attrs.get("__module__", "").rsplit(".", maxsplit=1)[-1], From 0d8a20e070e63bf1741abcd73073f7c0210fba0d Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sat, 12 Oct 2024 20:21:32 -0700 Subject: [PATCH 32/36] add logical plugin example to tutorial fix #122 --- django_typer/management/__init__.py | 1 + doc/source/changelog.rst | 3 +- doc/source/extensions.rst | 197 +++++++++++++++--- doc/source/refs.rst | 2 + .../management/commands/backup_typer.py | 4 + examples/plugins/backup_files/__init__.py | 0 examples/plugins/backup_files/apps.py | 8 + .../backup_files/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/backup.py | 41 ++++ .../management/commands/backup_typer.py | 42 ++++ examples/plugins/files1/__init__.py | 0 examples/plugins/files1/apps.py | 13 ++ .../plugins/files1/management/__init__.py | 0 .../files1/management/plugins/__init__.py | 0 .../files1/management/plugins/backup.py | 16 ++ .../files1/management/plugins/backup_typer.py | 17 ++ examples/plugins/files2/__init__.py | 0 examples/plugins/files2/apps.py | 13 ++ .../plugins/files2/management/__init__.py | 0 .../files2/management/plugins/__init__.py | 0 .../files2/management/plugins/backup.py | 16 ++ .../files2/management/plugins/backup_typer.py | 17 ++ pyproject.toml | 1 + .../management/commands/mod_on_init.py | 4 - tests/settings/backup_pluggy.py | 8 + tests/test_backup_example.py | 35 +++- tests/test_examples.py | 9 - tests/test_poll_example.py | 2 - 29 files changed, 397 insertions(+), 52 deletions(-) create mode 100644 examples/plugins/backup_files/__init__.py create mode 100644 examples/plugins/backup_files/apps.py create mode 100644 examples/plugins/backup_files/management/__init__.py create mode 100644 examples/plugins/backup_files/management/commands/__init__.py create mode 100644 examples/plugins/backup_files/management/commands/backup.py create mode 100644 examples/plugins/backup_files/management/commands/backup_typer.py create mode 100644 examples/plugins/files1/__init__.py create mode 100644 examples/plugins/files1/apps.py create mode 100644 examples/plugins/files1/management/__init__.py create mode 100644 examples/plugins/files1/management/plugins/__init__.py create mode 100644 examples/plugins/files1/management/plugins/backup.py create mode 100644 examples/plugins/files1/management/plugins/backup_typer.py create mode 100644 examples/plugins/files2/__init__.py create mode 100644 examples/plugins/files2/apps.py create mode 100644 examples/plugins/files2/management/__init__.py create mode 100644 examples/plugins/files2/management/plugins/__init__.py create mode 100644 examples/plugins/files2/management/plugins/backup.py create mode 100644 examples/plugins/files2/management/plugins/backup_typer.py create mode 100644 tests/settings/backup_pluggy.py diff --git a/django_typer/management/__init__.py b/django_typer/management/__init__.py index 8cebeb3b..e7046485 100644 --- a/django_typer/management/__init__.py +++ b/django_typer/management/__init__.py @@ -1951,6 +1951,7 @@ def __new__( attr_help = base.help def command_bases() -> t.Generator[t.Type[TyperCommand], None, None]: + # the mro is not yet resolved so we have to do it manually seen = set() for first_level in reversed(bases): for base in reversed(first_level.__mro__): diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 60795277..046c801f 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,7 +2,7 @@ Change Log ========== -v2.2.3 (2024-10-xx) +v2.3.0 (2024-10-xx) =================== * Implemented `Drop python 3.8 support. `_ @@ -10,6 +10,7 @@ v2.2.3 (2024-10-xx) * Fixed `Overriding the command group class does not work. `_ * Completed `Add project to test PyPI `_ * Completed `Open up vulnerability reporting and add security policy. `_ +* Completed `Add example of custom plugin logic to plugins tutorial. `_ * Completed `Move architecture in docs to ARCHITECTURE.md `_ * Completed `Transfer to django-commons `_ * Completed `Add howto for how to change the display order of commands in help. `_ diff --git a/doc/source/extensions.rst b/doc/source/extensions.rst index 58d09f10..166ba72c 100644 --- a/doc/source/extensions.rst +++ b/doc/source/extensions.rst @@ -6,17 +6,27 @@ Tutorial: Inheritance & Plugins =============================== -You may need to change the behavior of an -`upstream command `_ or wish -you could add an additional subcommand or group to it. django-typer_ offers two patterns for -changing or extending the behavior of commands. :class:`~django_typer.management.TyperCommand` -classes :ref:`support inheritance `, even multiple inheritance. This can be a way to -override or add additional commands to a command implemented elsewhere. You can then use Django's -built in command override precedence (INSTALLED_APPS) to ensure your command is used instead of the -upstream command or give it a different name if you would like the upstream command to still be -available. The :ref:`plugin pattern ` allows commands and groups to be added or overridden -directly on upstream commands without inheritance. This mechanism is useful when you might expect -other apps to also modify the original command. Conflicts are resolved in INSTALLED_APPS order. +Adding to, or altering the behavior of, commands from upstream Django_ apps is a common use case. +Doing so allows you to keep your CLI_ stable while adding additional behaviors through your site's +configuration settings files. There are three main extension patterns you may wish to employ: + + 1. Override the behavior of a command in an upstream app. + 2. Add additional subcommands or groups to a command in an upstream app. + 3. Hook implementations of custom logic into upstream command extension points. + (`Inversion of Control `_) + +The django-typer_ plugin mechanism supports all three of these use cases in a way that respects +the precedence order of apps in the ``INSTALLED_APPS`` setting. In this tutorial we walk through +an example of each using a :ref:`generic backup command `. First we'll see how we +might :ref:`use inheritance (1) ` to override and change the behavior of a +subcommand. Then we'll see how we can :ref:`add subcommands (2) ` to an upstream command +using plugins. Finally we'll use pluggy_ to implement a hook system that allows us to +:ref:`add custom logic (3) ` to an upstream command. + +.. _generic_backup: + +A Generic Backup Command +------------------------- Consider the task of backing up a Django website. State is stored in the database, in media files on disk, potentially in other files, and also in the software stack running on the server. If we @@ -68,8 +78,8 @@ Inheritance ----------- The first option we have is simple inheritance. Lets say the base command is defined in -an app called backup. Now say we have another app that adds functionality that uses media -files to our site. This means we'll want to add a media backup routine to the backup command. +an app called backup. Now say we have another app that uses media files. This means we'll +want to add a media backup routine to the backup command. .. note:: @@ -162,17 +172,14 @@ backup batch: [.............................................] Backing up ./media to ./media.tar.gz -.. warning:: - - Inheritance is not supported from typical Django commands that used argparse to define their - interface. +.. _inheritance_rationale: When Does Inheritance Make Sense? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Inheritance is a good choice when you want to tweak the behavior of a specific command and do not -expect other apps to also modify the same command. It's also a good choice when you want to offer +Inheritance is a good choice when you want to tweak the behavior of a specific command and **do not +expect other apps to also modify the same command**. It's also a good choice when you want to offer a different flavor of a command under a different name. What if other apps want to alter the same command and we don't know about them, but they may end up @@ -181,7 +188,7 @@ installed along with our app? This is where the plugin pattern will serve us bet .. _plugin: -Plugins +CLI Plugins ----------- **The plugin pattern allows us to add or override commands and groups on an upstream command @@ -194,10 +201,10 @@ than ``commands``. Let us suppose we are developing a site that uses the backup upstream and we've implemented most of our custom site functionality in a new app called my_app. Because we're now mostly working at the level of our particular site we may want to add more custom backup logic. For instance, lets say we know our site will always run on sqlite and we prefer -to just copy the file to backup our database. Lets also pretend that it is useful for us to backup -the python stack (e.g. requirements.txt) running on our server. To do that we can use the -plugin pattern to add our environment backup routine and override the database routine from -the upstream backup app. Our app tree now might look like this: +to just copy the file to backup our database. It is also useful for us to capture the python stack +(e.g. requirements.txt) running on our server. To do that we can use the plugin pattern to add our +environment backup routine and override the database routine from the upstream backup app. Our app +tree now might look like this: .. code-block:: text @@ -230,10 +237,9 @@ the upstream backup app. Our app tree now might look like this: └── backup.py -Note that we've added an ``plugins`` directory to the management directory of the media and -my_app apps. This is where we'll place our extension commands. There is an additional step we must -take. In the ``apps.py`` file of the media and my_app apps we must register our plugins like -this: +Note that we've added a ``plugins`` directory to the management directory of the ``media`` and +``my_app`` apps. This is where we'll place our command extensions. We must register our plugins +directory in the ``apps.py`` file of the media and my_app apps like this: .. code-block:: python @@ -253,14 +259,14 @@ this: Because we explicitly register our plugins we can call the package whatever we want. django-typer does not require it to be named ``plugins``. It is also important to do this inside ready() because conflicts are resolved in the order in which the extension - modules are registered and ready() methods are called in INSTALLED_APPS order. + modules are registered and ready() methods are called in ``INSTALLED_APPS`` order. -For plugins to work, we'll need to re-mplement media from above as a composed extension -and that would look like this: +For plugins to work, we'll need to re-implement media from above as a composed extension +like this: .. tabs:: - .. tab:: django-typer + .. tab:: Django-style .. literalinclude:: ../../tests/apps/examples/plugins/media2/management/plugins/backup.py :language: python @@ -425,8 +431,10 @@ You may even override the initializer of a predefined group: (i.e. ``Command.grp1.grp2.grp3.cmd``), if there is only one cmd you can simply write ``Command.cmd``. However, using the strict hierarchy will be robust to future changes. -When Do Plugins Make Sense? -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. _cli_plugin_rationale: + +When Do CLI Plugins Make Sense? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Plugins can be used to group like behavior together under a common root command. This can be thought of as a way to namespace CLI tools or easily share significant code between tools that have @@ -441,3 +449,124 @@ Plugins can be a good way to organize commands in a code base that follows this also allows for deployments that install a subset of those apps and is therefore a good way to organize commands in code bases that serve as a framework for a particular kind of site or that support selecting the features to install by the inclusion or exclusion of specific apps. + + +.. _hooks: + +Logic Plugins +------------- + +`Inversion of Control (IoC) `_ is a design +pattern that allows you to inject custom logic into a framework or library. The framework defines +the general execution flow with extension points along the way that downstream applications can +provide the implementations for. Django uses IoC all over the place. Extension points are often +called ``hooks``. **You may use a third party library to manage hooks or implement your own +mechanism but you will always need to register hook implementations. The same plugin mechanism we +used in the** :ref:`last section ` **provides a natural place to do this.** + +Some Django_ apps may keep state in files in places on the filesystem unknown to other parts of +your code base. In this section we'll use pluggy_ to define a hook for other apps to implement to +backup their own files. Let's: + +1. Create a new app ``backup_files`` and inherit from our the extended media backup command we + created in the :ref:`inheritance section `. +2. Define a pluggy_ interface for backing up arbitrary files +3. Add a ``files`` command to our backup command that will call all registered + hooks to backup their own files. + +.. tabs:: + + .. tab:: Django-style + + .. literalinclude:: ../../tests/apps/examples/plugins/backup_files/management/commands/backup.py + :language: python + :caption: backup_files/management/commands/backup.py + :linenos: + :replace: + tests.apps.examples.plugins.media1: media + + .. tab:: Typer-style + + .. literalinclude:: ../../tests/apps/examples/plugins/backup_files/management/commands/backup_typer.py + :language: python + :caption: backup_files/management/commands/backup.py + :linenos: + :replace: + tests.apps.examples.plugins.media1: media + +Now lets define two new apps, files1 and files2 that will provide and register implementations of +the backup_files hook: + +.. tabs:: + + .. tab:: Django-style + + .. literalinclude:: ../../tests/apps/examples/plugins/files1/management/plugins/backup.py + :language: python + :caption: files1/management/plugins/backup.py + :linenos: + :replace: + tests.apps.examples.plugins.backup_files: backup_files + + .. tab:: Typer-style + + .. literalinclude:: ../../tests/apps/examples/plugins/files1/management/plugins/backup_typer.py + :language: python + :caption: files1/management/plugins/backup.py + :linenos: + :replace: + tests.apps.examples.plugins.backup_files: backup_files + +.. tabs:: + + .. tab:: Django-style + + .. literalinclude:: ../../tests/apps/examples/plugins/files2/management/plugins/backup.py + :language: python + :caption: files2/management/plugins/backup.py + :linenos: + :replace: + tests.apps.examples.plugins.backup_files: backup_files + + .. tab:: Typer-style + + .. literalinclude:: ../../tests/apps/examples/plugins/files2/management/plugins/backup_typer.py + :language: python + :caption: files2/management/plugins/backup.py + :linenos: + :replace: + tests.apps.examples.plugins.backup_files: backup_files + +Both ``files1`` and ``files2`` will need to register their plugin packages in their ``apps.py`` +file: + +.. literalinclude:: ../../tests/apps/examples/plugins/files1/apps.py + :language: python + :caption: files1/apps.py + :linenos: + :replace: + tests.apps.examples.plugins.files1: files1 + +Now when we run we see: + +.. code-block:: bash + + $> python manage.py backup + Backing up database [default] to: ./default.json + [.............................................] + Backing up ./media to ./media.tar.gz + Backed up files to ./files2.zip + Backed up files to ./files1.tar.gz + + +When Do Logic Plugins Make Sense? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:ref:`CLI plugins make sense ` when you want to add additional commands or +under a common namespace or to override the entire behavior of a command. Logical plugins make +more sense in the weeds of a particular subroutine. Our example above has the following qualities +which makes it a good candidate: + +1. The logic makes sense under a common root name (e.g. ``./manage.py backup files``). +2. Multiple apps may need to execute their own version of the logic to complete the operation. +3. The logic is amenable to a common interface that all plugins can implement. diff --git a/doc/source/refs.rst b/doc/source/refs.rst index d43189ef..a5297bbe 100644 --- a/doc/source/refs.rst +++ b/doc/source/refs.rst @@ -17,3 +17,5 @@ .. _Options: https://typer.tiangolo.com/tutorial/options/ .. _call_command: https://docs.djangoproject.com/en/5.0/ref/django-admin/#running-management-commands-from-your-code .. _sphinxcontrib-typer: https://pypi.org/project/sphinxcontrib-typer/ +.. _pluggy: https://pluggy.readthedocs.io/ +.. _CLI: https://en.wikipedia.org/wiki/Command-line_interface diff --git a/examples/plugins/backup/management/commands/backup_typer.py b/examples/plugins/backup/management/commands/backup_typer.py index ddcd1884..a5d0813c 100644 --- a/examples/plugins/backup/management/commands/backup_typer.py +++ b/examples/plugins/backup/management/commands/backup_typer.py @@ -12,6 +12,10 @@ app = Typer() +# these two lines are not necessary but will make your type checker happy +assert app.django_command +Command = app.django_command + Command.suppressed_base_arguments = {"verbosity", "skip_checks"} Command.requires_migrations_checks = False Command.requires_system_checks = [] diff --git a/examples/plugins/backup_files/__init__.py b/examples/plugins/backup_files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/backup_files/apps.py b/examples/plugins/backup_files/apps.py new file mode 100644 index 00000000..4a1d99e4 --- /dev/null +++ b/examples/plugins/backup_files/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + +from django_typer.utils import register_command_plugins + + +class BackupFilesConfig(AppConfig): + name = "tests.apps.examples.plugins.backup_files" + label = name.replace(".", "_") diff --git a/examples/plugins/backup_files/management/__init__.py b/examples/plugins/backup_files/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/backup_files/management/commands/__init__.py b/examples/plugins/backup_files/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/backup_files/management/commands/backup.py b/examples/plugins/backup_files/management/commands/backup.py new file mode 100644 index 00000000..08da33b9 --- /dev/null +++ b/examples/plugins/backup_files/management/commands/backup.py @@ -0,0 +1,41 @@ +import sys +import typing as t +from pathlib import Path + +import typer +import pluggy + +from tests.apps.examples.plugins.media1.management.commands.backup import ( + Command as Backup, +) + + +class Command(Backup): # inherit from the extended media backup command + plugins = pluggy.PluginManager("backup") + hookspec = pluggy.HookspecMarker("backup") + hookimpl = pluggy.HookimplMarker("backup") + + # add a new command called files that delegates file backups to plugins + @Backup.command() + def files(self): + """ + Backup app specific non-media files. + """ + for archive in self.plugins.hook.backup_files(command=self): + if archive: + typer.echo(f"Backed up files to {archive}") + + +@Command.hookspec +def backup_files(command: Command) -> t.Optional[Path]: + """ + A hook for backing up app specific files. + + Must return the path to the archive file or None if no files were backed up. + + :param command: the backup command instance + :return: The path to the archived backup file + """ + + +Command.plugins.add_hookspecs(sys.modules[__name__]) diff --git a/examples/plugins/backup_files/management/commands/backup_typer.py b/examples/plugins/backup_files/management/commands/backup_typer.py new file mode 100644 index 00000000..f3371886 --- /dev/null +++ b/examples/plugins/backup_files/management/commands/backup_typer.py @@ -0,0 +1,42 @@ +import sys +import typing as t +from pathlib import Path + +import typer +import pluggy + +from django_typer.management import Typer +from tests.apps.examples.plugins.media1.management.commands import backup_typer + +app = Typer(backup_typer.app) + +# pluggy artifacts can live at module scope +plugins = pluggy.PluginManager("backup") +hookspec = pluggy.HookspecMarker("backup") +hookimpl = pluggy.HookimplMarker("backup") + + +# add a new command called files that delegates file backups to plugins +@app.command() +def files(self): + """ + Backup app specific non-media files. + """ + for archive in plugins.hook.backup_files(command=self): + if archive: + typer.echo(f"Backed up files to {archive}") + + +@hookspec +def backup_files(command) -> t.Optional[Path]: + """ + A hook for backing up app specific files. + + Must return the path to the archive file or None if no files were backed up. + + :param command: the backup command instance + :return: The path to the archived backup file + """ + + +plugins.add_hookspecs(sys.modules[__name__]) diff --git a/examples/plugins/files1/__init__.py b/examples/plugins/files1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/files1/apps.py b/examples/plugins/files1/apps.py new file mode 100644 index 00000000..4f0c15d0 --- /dev/null +++ b/examples/plugins/files1/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + +from django_typer.utils import register_command_plugins + + +class Files1Config(AppConfig): + name = "tests.apps.examples.plugins.files1" + label = name.replace(".", "_") + + def ready(self): + from .management import plugins + + register_command_plugins(plugins) diff --git a/examples/plugins/files1/management/__init__.py b/examples/plugins/files1/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/files1/management/plugins/__init__.py b/examples/plugins/files1/management/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/files1/management/plugins/backup.py b/examples/plugins/files1/management/plugins/backup.py new file mode 100644 index 00000000..f2581602 --- /dev/null +++ b/examples/plugins/files1/management/plugins/backup.py @@ -0,0 +1,16 @@ +import sys +import typing as t +from pathlib import Path + +from tests.apps.examples.plugins.backup_files.management.commands.backup import ( + Command as Backup, +) + + +@Backup.hookimpl +def backup_files(command: Backup) -> t.Optional[Path]: + # this is where you would put your custom file backup logic + return command.output_directory / "files1.tar.gz" + + +Backup.plugins.register(sys.modules[__name__]) diff --git a/examples/plugins/files1/management/plugins/backup_typer.py b/examples/plugins/files1/management/plugins/backup_typer.py new file mode 100644 index 00000000..6c031732 --- /dev/null +++ b/examples/plugins/files1/management/plugins/backup_typer.py @@ -0,0 +1,17 @@ +import sys +import typing as t +from pathlib import Path + +from tests.apps.examples.plugins.backup_files.management.commands.backup_typer import ( + plugins, + hookimpl, +) + + +@hookimpl +def backup_files(command) -> t.Optional[Path]: + # this is where you would put your custom file backup logic + return command.output_directory / "files1.tar.gz" + + +plugins.register(sys.modules[__name__]) diff --git a/examples/plugins/files2/__init__.py b/examples/plugins/files2/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/files2/apps.py b/examples/plugins/files2/apps.py new file mode 100644 index 00000000..9eef0324 --- /dev/null +++ b/examples/plugins/files2/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + +from django_typer.utils import register_command_plugins + + +class Files2Config(AppConfig): + name = "tests.apps.examples.plugins.files2" + label = name.replace(".", "_") + + def ready(self): + from .management import plugins + + register_command_plugins(plugins) diff --git a/examples/plugins/files2/management/__init__.py b/examples/plugins/files2/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/files2/management/plugins/__init__.py b/examples/plugins/files2/management/plugins/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/plugins/files2/management/plugins/backup.py b/examples/plugins/files2/management/plugins/backup.py new file mode 100644 index 00000000..61bf01ee --- /dev/null +++ b/examples/plugins/files2/management/plugins/backup.py @@ -0,0 +1,16 @@ +import sys +import typing as t +from pathlib import Path + +from tests.apps.examples.plugins.backup_files.management.commands.backup import ( + Command as Backup, +) + + +@Backup.hookimpl +def backup_files(command: Backup) -> t.Optional[Path]: + # this is where you would put your custom file backup logic + return command.output_directory / "files2.zip" + + +Backup.plugins.register(sys.modules[__name__]) diff --git a/examples/plugins/files2/management/plugins/backup_typer.py b/examples/plugins/files2/management/plugins/backup_typer.py new file mode 100644 index 00000000..05593d07 --- /dev/null +++ b/examples/plugins/files2/management/plugins/backup_typer.py @@ -0,0 +1,17 @@ +import sys +import typing as t +from pathlib import Path + +from tests.apps.examples.plugins.backup_files.management.commands.backup_typer import ( + plugins, + hookimpl, +) + + +@hookimpl +def backup_files(command) -> t.Optional[Path]: + # this is where you would put your custom file backup logic + return command.output_directory / "files2.zip" + + +plugins.register(sys.modules[__name__]) diff --git a/pyproject.toml b/pyproject.toml index bf7d3c8e..ffe7b45b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ ruff = ">=0.4.1" graphviz = ">=0.20.3" sphinx-tabs = ">=3.4.5" furo = ">=2024.7.18" +pluggy = "^1.5.0" [tool.poetry.extras] rich = ["rich"] diff --git a/tests/apps/test_app/management/commands/mod_on_init.py b/tests/apps/test_app/management/commands/mod_on_init.py index cb4810c7..d978eb79 100644 --- a/tests/apps/test_app/management/commands/mod_on_init.py +++ b/tests/apps/test_app/management/commands/mod_on_init.py @@ -5,10 +5,6 @@ from django_typer.management import TyperCommand, initialize -if sys.version_info < (3, 9): - pass -else: - pass COMMAND_TMPL = """ def {routine}(self, {flag_args}): diff --git a/tests/settings/backup_pluggy.py b/tests/settings/backup_pluggy.py new file mode 100644 index 00000000..0bb3b400 --- /dev/null +++ b/tests/settings/backup_pluggy.py @@ -0,0 +1,8 @@ +from .backup_inherit import * + +INSTALLED_APPS = [ + "tests.apps.examples.plugins.files2", + "tests.apps.examples.plugins.files1", + "tests.apps.examples.plugins.backup_files", + *INSTALLED_APPS, +] diff --git a/tests/test_backup_example.py b/tests/test_backup_example.py index e713c734..7c0b5e7e 100644 --- a/tests/test_backup_example.py +++ b/tests/test_backup_example.py @@ -12,7 +12,6 @@ BACKUP_DIRECTORY = Path(__file__).parent / "_test_archive" -@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python3.9 or higher") class TestBackupExample(SimpleTestCase): databases = {"default"} @@ -116,7 +115,39 @@ def test_extend_backup(self): self.assertTrue((BACKUP_DIRECTORY / "requirements.txt").exists()) self.assertTrue(len(os.listdir(BACKUP_DIRECTORY)) == 3) + def test_backup_pluggy(self): + stdout, stderr, retcode = run_command( + f"backup{self.typer}", + "--no-color", + "--settings", + "tests.settings.backup_pluggy", + "list", + ) + self.assertEqual(retcode, 0, msg=stderr) + lines = [line.strip() for line in stdout.strip().splitlines()[1:]] + self.assertEqual(len(lines), 3) + self.assertTrue( + "database(filename={database}.json, databases=['default'])" in lines + ) + self.assertTrue("media(filename=media.tar.gz)" in lines) + self.assertTrue("files()" in lines) + + stdout, stderr, retcode = run_command( + f"backup{self.typer}", + "--settings", + "tests.settings.backup_pluggy", + "-o", + str(BACKUP_DIRECTORY), + ) + self.assertEqual(retcode, 0, msg=stderr) + self.assertTrue(BACKUP_DIRECTORY.exists()) + self.assertTrue((BACKUP_DIRECTORY / "default.json").exists()) + self.assertTrue((BACKUP_DIRECTORY / "media.tar.gz").exists()) + self.assertTrue(len(os.listdir(BACKUP_DIRECTORY)) == 2) + + self.assertTrue("files1.tar.gz" in stdout) + self.assertTrue("files2.zip" in stdout) + -@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python3.9 or higher") class TestBackupTyperExample(TestBackupExample): typer = "_typer" diff --git a/tests/test_examples.py b/tests/test_examples.py index f52c7262..adb1d488 100644 --- a/tests/test_examples.py +++ b/tests/test_examples.py @@ -317,9 +317,6 @@ def test_basic(self): 0.99, ) - @pytest.mark.skipif( - sys.version_info < (3, 9), reason="Readme examples only run on python > 3.8" - ) def test_multi(self): observed_help = run_command( "multi", "--settings", self.settings, "--no-color", "--help" @@ -353,9 +350,6 @@ def test_multi(self): 0.99, ) - @pytest.mark.skipif( - sys.version_info < (3, 9), reason="Readme examples only run on python > 3.8" - ) def test_hierarchy(self): observed_help = run_command( "hierarchy", "--settings", self.settings, "--no-color", "--help" @@ -456,9 +450,6 @@ class TyperExampleTests(ExampleTests): settings = "tests.settings.typer_examples" -@pytest.mark.skipif( - sys.version_info < (3, 9), reason="Readme examples only run on python > 3.8" -) @override_settings( INSTALLED_APPS=[ "tests.apps.examples.completers", diff --git a/tests/test_poll_example.py b/tests/test_poll_example.py index 9ce297ec..4e283f1d 100644 --- a/tests/test_poll_example.py +++ b/tests/test_poll_example.py @@ -19,7 +19,6 @@ ] -@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python 3.9+") class TestPollExample(SimpleTestCase): q1 = None q2 = None @@ -115,6 +114,5 @@ def test_poll_ex(self): self.assertTrue("Successfully closed poll" in result[0]) -@pytest.mark.skipif(sys.version_info < (3, 9), reason="requires python 3.9+") class TestPollExampleTyper(SimpleTestCase): typer = "_typer" From ece6da70d82ddfdc67d18f07737b1689830b90ef Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sat, 12 Oct 2024 20:22:58 -0700 Subject: [PATCH 33/36] fix doc linting issue --- doc/source/extensions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/source/extensions.rst b/doc/source/extensions.rst index 166ba72c..9d1b53c5 100644 --- a/doc/source/extensions.rst +++ b/doc/source/extensions.rst @@ -483,7 +483,7 @@ backup their own files. Let's: :caption: backup_files/management/commands/backup.py :linenos: :replace: - tests.apps.examples.plugins.media1: media + tests.apps.examples.plugins.media1: media .. tab:: Typer-style From 9456d558b9b3b4a1e6a2e99aba8c1cc976edddcf Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sat, 12 Oct 2024 20:23:49 -0700 Subject: [PATCH 34/36] update change log --- doc/source/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index 046c801f..dfcd1ee3 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -5,6 +5,7 @@ Change Log v2.3.0 (2024-10-xx) =================== +* Fixed `Inheritance more than one level deep of TyperCommands does not work. `_ * Implemented `Drop python 3.8 support. `_ * Implemented `Command help order should respect definition order for class based commands. `_ * Fixed `Overriding the command group class does not work. `_ From bb33af29f974a6acebe51256f787cf3d32d59213 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sat, 12 Oct 2024 20:25:28 -0700 Subject: [PATCH 35/36] increment version number, date release --- django_typer/__init__.py | 2 +- doc/source/changelog.rst | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/django_typer/__init__.py b/django_typer/__init__.py index ad2df652..4613565c 100644 --- a/django_typer/__init__.py +++ b/django_typer/__init__.py @@ -47,7 +47,7 @@ model_parser_completer, # noqa: F401 ) -VERSION = (2, 2, 2) +VERSION = (2, 3, 0) __title__ = "Django Typer" __version__ = ".".join(str(i) for i in VERSION) diff --git a/doc/source/changelog.rst b/doc/source/changelog.rst index dfcd1ee3..2d1f64f4 100644 --- a/doc/source/changelog.rst +++ b/doc/source/changelog.rst @@ -2,7 +2,7 @@ Change Log ========== -v2.3.0 (2024-10-xx) +v2.3.0 (2024-10-13) =================== * Fixed `Inheritance more than one level deep of TyperCommands does not work. `_ diff --git a/pyproject.toml b/pyproject.toml index ffe7b45b..5641cd70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "django-typer" -version = "2.2.2" +version = "2.3.0" description = "Use Typer to define the CLI for your Django management commands." authors = [ "Brian Kohan ", From e0e213ab350d6200ef02b42b84072ce39667e2d8 Mon Sep 17 00:00:00 2001 From: Brian Kohan Date: Sat, 12 Oct 2024 22:26:56 -0700 Subject: [PATCH 36/36] add diamond inheritance test #131 --- .../management/commands/inheritance1.py | 36 +++++++++++ .../management/commands/inheritance2_1.py | 43 +++++++++++++ .../management/commands/inheritance2_2.py | 43 +++++++++++++ .../management/commands/inheritance3.py | 20 ++++++ tests/test_inheritance.py | 61 +++++++++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 tests/apps/test_app/management/commands/inheritance1.py create mode 100644 tests/apps/test_app/management/commands/inheritance2_1.py create mode 100644 tests/apps/test_app/management/commands/inheritance2_2.py create mode 100644 tests/apps/test_app/management/commands/inheritance3.py diff --git a/tests/apps/test_app/management/commands/inheritance1.py b/tests/apps/test_app/management/commands/inheritance1.py new file mode 100644 index 00000000..1f9c7f52 --- /dev/null +++ b/tests/apps/test_app/management/commands/inheritance1.py @@ -0,0 +1,36 @@ +""" +Test that multi-level and multiple inheritance resolves correctly. +""" + +from django_typer.management import TyperCommand, command, group, initialize + + +class Command(TyperCommand): + """ + Inheritance1 + """ + + @initialize(invoke_without_command=True) + def init(self): + assert isinstance(self, Command) + return "inheritance1::init()" + + @command() + def a(self): + assert isinstance(self, Command) + return "inheritance1::a()" + + @group(invoke_without_command=True) + def g(self): + assert isinstance(self, Command) + return "inheritance1::g()" + + @g.command() + def ga(self): + assert isinstance(self, Command) + return "inheritance1::g::ga()" + + @g.command() + def gb(self): + assert isinstance(self, Command) + return "inheritance1::g::gb()" diff --git a/tests/apps/test_app/management/commands/inheritance2_1.py b/tests/apps/test_app/management/commands/inheritance2_1.py new file mode 100644 index 00000000..76c7ef29 --- /dev/null +++ b/tests/apps/test_app/management/commands/inheritance2_1.py @@ -0,0 +1,43 @@ +from django_typer.management import TyperCommand, command, group, initialize +from .inheritance1 import Command as Inheritance1Command + + +class Command(Inheritance1Command): + """ + Inheritance2_1 + """ + + @initialize(invoke_without_command=True) + def init(self): + assert isinstance(self, Command) + return "inheritance2_1::init()" + + @command() + def a(self): + assert isinstance(self, Command) + return "inheritance2_1::a()" + + @command() + def b(self): + assert isinstance(self, Command) + return "inheritance2_1::b()" + + @group() + def g(self): + assert isinstance(self, Command) + return "inheritance2_1::g()" + + @g.command() + def ga(self): + assert isinstance(self, Command) + return "inheritance2_1::g::ga()" + + @g.command() + def gb(self): + assert isinstance(self, Command) + return "inheritance2_1::g::gb()" + + @g.command() + def gc(self): + assert isinstance(self, Command) + return "inheritance2_1::g::gc()" diff --git a/tests/apps/test_app/management/commands/inheritance2_2.py b/tests/apps/test_app/management/commands/inheritance2_2.py new file mode 100644 index 00000000..3824bf5b --- /dev/null +++ b/tests/apps/test_app/management/commands/inheritance2_2.py @@ -0,0 +1,43 @@ +from django_typer.management import TyperCommand, command, group, initialize +from .inheritance1 import Command as Inheritance1Command + + +class Command(Inheritance1Command): + """ + Inheritance2_2 + """ + + @initialize(invoke_without_command=True) + def init(self): + assert isinstance(self, Command) + return "inheritance2_2::init()" + + @command() + def a(self): + assert isinstance(self, Command) + return "inheritance2_2::a()" + + @command() + def b(self): + assert isinstance(self, Command) + return "inheritance2_2::b()" + + @command() + def c(self): + assert isinstance(self, Command) + return "inheritance2_2::c()" + + @group() + def g(self): + assert isinstance(self, Command) + return "inheritance2_2::g()" + + @g.command() + def ga(self): + assert isinstance(self, Command) + return "inheritance2_2::g::ga()" + + @group(invoke_without_command=True) + def g2(self): + assert isinstance(self, Command) + return "inheritance2_2::g2()" diff --git a/tests/apps/test_app/management/commands/inheritance3.py b/tests/apps/test_app/management/commands/inheritance3.py new file mode 100644 index 00000000..364a2752 --- /dev/null +++ b/tests/apps/test_app/management/commands/inheritance3.py @@ -0,0 +1,20 @@ +from django_typer.management import TyperCommand, command, group, initialize +from .inheritance2_1 import Command as Inheritance21Command +from .inheritance2_2 import Command as Inheritance22Command + + +class Command(Inheritance21Command, Inheritance22Command): + @command() + def b(self): + assert isinstance(self, Command) + return "inheritance3::b()" + + @command() + def d(self): + assert isinstance(self, Command) + return "inheritance3::d()" + + @Inheritance22Command.g2.command() + def g2a(self): + assert isinstance(self, Command) + return "inheritance3::g2::g2a()" diff --git a/tests/test_inheritance.py b/tests/test_inheritance.py index 88acd559..e7098ab5 100644 --- a/tests/test_inheritance.py +++ b/tests/test_inheritance.py @@ -29,3 +29,64 @@ def test_handle_override(self): "class": "", }, ) + + +class TestDiamondInheritance(TestCase): + """ + Multiple inheritance works!! Even diamond inheritance! + """ + + def test_diamond_inheritance_run(self): + stdout, _, retcode = run_command("inheritance3") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_1::init()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "a") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_1::a()" in stdout) + + stdout, _, retcode = run_command("inheritance2_2", "a") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_2::a()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "b") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance3::b()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "c") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_2::c()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "g2", "g2a") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance3::g2::g2a()" in stdout) + + stdout, _, retcode = run_command("inheritance1", "g") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance1::g()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "g") + self.assertGreater(retcode, 0) + + stdout, _, retcode = run_command("inheritance3", "g", "ga") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_1::g::ga()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "g", "gb") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_1::g::gb()" in stdout) + + stdout, _, retcode = run_command("inheritance3", "g", "gc") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance2_1::g::gc()" in stdout) + + stdout, _, retcode = run_command("inheritance1", "g", "gc") + self.assertGreater(retcode, 0) + + stdout, _, retcode = run_command("inheritance1", "g", "ga") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance1::g::ga()" in stdout) + + stdout, _, retcode = run_command("inheritance1", "g", "gb") + self.assertEqual(retcode, 0) + self.assertTrue("inheritance1::g::gb()" in stdout)