diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aaefe5d..599ed3a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -48,22 +48,22 @@ jobs: poetry export --without-hashes --format=requirements.txt | poetry run safety check --stdin poetry run python -m readme_renderer ./README.rst -o /tmp/README.html - build: + test: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] django-version: - - 'Django~=3.2.0' # LTS April 2024 - - 'Django~=4.2.0' # LTS April 2026 - - 'Django~=5.0.0' # April 2025 + - '3.2' # LTS April 2024 + - '4.2' # LTS April 2026 + - '5.0' # April 2025 exclude: - python-version: '3.9' - django-version: 'Django~=5.0.0' + django-version: '5.0' - python-version: '3.11' - django-version: 'Django~=3.2.0' + django-version: '3.2' - python-version: '3.12' - django-version: 'Django~=3.2.0' + django-version: '3.2' steps: - uses: actions/checkout@v3 @@ -85,14 +85,54 @@ jobs: poetry config virtualenvs.in-project true poetry run pip install --upgrade pip poetry install - poetry run pip install -U "${{ matrix.django-version }}" + poetry run pip install -U "Django~=${{ matrix.django-version }}" - name: Run Unit Tests run: | poetry run pytest + mv .coverage py${{ matrix.python-version }}-dj${{ matrix.django-version }}.coverage - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + - name: Store coverage files + uses: actions/upload-artifact@v4 with: - file: ./coverage.xml + name: coverage-py${{ matrix.python-version }}-dj${{ matrix.django-version }} + path: py${{ matrix.python-version }}-dj${{ matrix.django-version }}.coverage + + coverage-combine: + needs: [test] + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + + - name: Install Release Dependencies + run: | + poetry config virtualenvs.in-project true + poetry run pip install --upgrade pip + poetry install + + - name: Get coverage files + uses: actions/download-artifact@v4 + with: + pattern: coverage-* + merge-multiple: true + + - run: ls -la *.coverage + - run: poetry run coverage combine *.coverage + - run: poetry run coverage report + - run: poetry run coverage xml + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + token: ${{ secrets.CODECOV_TOKEN }} + file: ./coverage.xml + \ No newline at end of file diff --git a/django_typer/__init__.py b/django_typer/__init__.py index 09b1904..10b72ae 100644 --- a/django_typer/__init__.py +++ b/django_typer/__init__.py @@ -17,6 +17,7 @@ from types import MethodType, SimpleNamespace import click +from click.shell_completion import CompletionItem from django.conf import settings from django.core.management import get_commands from django.core.management.base import BaseCommand @@ -60,6 +61,7 @@ "command", "group", "get_command", + "COMPLETE_VAR" ] """ @@ -87,6 +89,7 @@ # COLOR_SYSTEM = lazy(get_color_system, str) # rich_utils.COLOR_SYSTEM = COLOR_SYSTEM(rich_utils.COLOR_SYSTEM) +COMPLETE_VAR = "_COMPLETE_INSTRUCTION" def traceback_config(): cfg = getattr(settings, "DT_RICH_TRACEBACK_CONFIG", {"show_locals": True}) @@ -234,6 +237,17 @@ class DjangoAdapterMixin: # pylint: disable=too-few-public-methods callback_is_method: bool = True param_converters: t.Dict[str, t.Callable[..., t.Any]] = {} + def shell_complete(self, ctx: Context, incomplete: str) -> t.List[CompletionItem]: + """ + By default if the incomplete string is a space and there are no completions + the click infrastructure will return _files. We'd rather return parameters + for the command if there are any available. + """ + completions = super().shell_complete(ctx, incomplete) + if not completions and (incomplete.isspace() or not incomplete) and getattr(ctx, '_opt_prefixes', None): + completions = super().shell_complete(ctx, min(ctx._opt_prefixes)) + return completions + def common_params(self): return [] @@ -657,6 +671,7 @@ def handle(self, *args, **options): standalone_mode=False, supplied_params=options, django_command=self, + complete_var=None, prog_name=f"{sys.argv[0]} {self.typer_app.info.name}", ) diff --git a/django_typer/management/commands/shellcompletion.py b/django_typer/management/commands/shellcompletion.py index 4b375ad..fef08b1 100644 --- a/django_typer/management/commands/shellcompletion.py +++ b/django_typer/management/commands/shellcompletion.py @@ -20,7 +20,7 @@ from typer import Argument, Option, echo from typer.completion import Shells, completion_init -from django_typer import TyperCommand, command, get_command +from django_typer import TyperCommand, command, get_command, COMPLETE_VAR try: from shellingham import detect_shell @@ -69,8 +69,6 @@ class Command(TyperCommand): _shell: Shells - COMPLETE_VAR = "_COMPLETE_INSTRUCTION" - @cached_property def manage_script(self) -> t.Union[str, Path]: """ @@ -137,8 +135,8 @@ def shell(self): self, "_shell", Shells( - os.environ[self.COMPLETE_VAR].partition("_")[2] - if self.COMPLETE_VAR in os.environ + os.environ[COMPLETE_VAR].partition("_")[2] + if COMPLETE_VAR in os.environ else detect_shell()[0] ), ) @@ -279,7 +277,7 @@ def install( install_path = install( shell=self.shell.value, prog_name=manage_script or self.manage_script_name, - complete_var=self.COMPLETE_VAR, + complete_var=COMPLETE_VAR, )[1] self.stdout.write( self.style.SUCCESS( @@ -388,18 +386,27 @@ def get_completion_args(self) -> t.Tuple[t.List[str], str]: except Exception: pass return ( - cwords, - cwords[-1] if len(cwords) and not command[-1].isspace() else "", + cwords[:-1], + cwords[-1] if len(cwords) and not command[-1].isspace() else ' ', ) CompletionClass.get_completion_args = get_completion_args - add_completion_class(self.shell.value, CompletionClass) + + _get_completions = CompletionClass.get_completions + def get_completions(self, args, incomplete): + """ + need to remove the django command name from the arg completions + """ + return _get_completions(self, args[1:], incomplete) + CompletionClass.get_completions = get_completions + + add_completion_class(self.shell.value, CompletionClass) args, incomplete = CompletionClass( - cli=self.noop_command, - ctx_args=self.noop_command, + cli=self.noop_command.command, + ctx_args={}, prog_name=sys.argv[0], - complete_var=self.COMPLETE_VAR, + complete_var=COMPLETE_VAR, ).get_completion_args() def call_fallback(fb): @@ -413,21 +420,24 @@ def call_fallback(fb): call_fallback(fallback) else: try: - os.environ[self.COMPLETE_VAR] = os.environ.get( - self.COMPLETE_VAR, f"complete_{self.shell.value}" + os.environ[COMPLETE_VAR] = os.environ.get( + COMPLETE_VAR, f"complete_{self.shell.value}" ) cmd = get_command(args[0]) - if isinstance(cmd, TyperCommand): - # invoking the command will trigger the autocompletion? - cmd.command_tree.command._main_shell_completion( - ctx_args={}, - prog_name=f"{sys.argv[0]} {cmd.command_tree.command.name}", - complete_var=self.COMPLETE_VAR, - ) - return - else: - call_fallback(fallback) - except Exception as e: + except Exception: + call_fallback(fallback) + return + + if isinstance(cmd, TyperCommand): + cmd.typer_app( + args=args[1:], + standalone_mode=True, + django_command=cmd, + complete_var=COMPLETE_VAR, + prog_name=f"{sys.argv[0]} {self.typer_app.info.name}", + ) + return + else: call_fallback(fallback) def django_fallback(self): @@ -462,10 +472,10 @@ def get_completions(self, args, incomplete): CompletionClass.get_completions = get_completions echo( CompletionClass( - cli=self.noop_command, + cli=self.noop_command.command, ctx_args={}, prog_name=self.manage_script_name, - complete_var=self.COMPLETE_VAR, + complete_var=COMPLETE_VAR, ).complete() ) diff --git a/django_typer/tests/test_app/management/commands/completion.py b/django_typer/tests/test_app/management/commands/completion.py index 49b2ef0..953b3a8 100644 --- a/django_typer/tests/test_app/management/commands/completion.py +++ b/django_typer/tests/test_app/management/commands/completion.py @@ -10,10 +10,6 @@ def parse_app_label(label: t.Union[str, AppConfig]): - if label == "django_apps": - import ipdb - - ipdb.set_trace() if isinstance(label, AppConfig): return label return apps.get_app_config(label) diff --git a/django_typer/tests/typer_test.py b/django_typer/tests/typer_test.py index 0313d42..86f6eed 100755 --- a/django_typer/tests/typer_test.py +++ b/django_typer/tests/typer_test.py @@ -15,13 +15,13 @@ def create(username: str, flag: bool = False): print(f"flag: {flag}") -# @app.command(epilog="Delete Epilog") -# def delete(username: str): -# if state["verbose"]: -# print("About to delete a user") -# print(f"Deleting user: {username}") -# if state["verbose"]: -# print("Just deleted a user") +@app.command(epilog="Delete Epilog") +def delete(username: str): + if state["verbose"]: + print("About to delete a user") + print(f"Deleting user: {username}") + if state["verbose"]: + print("Just deleted a user") @app.callback(epilog="Main Epilog") diff --git a/pyproject.toml b/pyproject.toml index a44b7a9..a7fdcdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,8 +118,6 @@ addopts = [ "--cov=django_typer", "--cov-branch", "--cov-report=term-missing:skip-covered", - "--cov-report=html", - "--cov-report=xml", "--cov-fail-under=90" ]