diff --git a/.vscode/launch.json b/.vscode/launch.json index 097ef515f..fdebcd587 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "name": "Django: Benefits Client", "type": "python", "request": "launch", - "program": "${workspaceFolder}/manage.py", + "program": "${workspaceFolder}/benefits/cli/main.py", "args": ["runserver", "--insecure", "0.0.0.0:8000"], "django": true, "env": { @@ -20,13 +20,25 @@ "name": "Django: Benefits Client, Debug=False", "type": "python", "request": "launch", - "program": "${workspaceFolder}/manage.py", + "program": "${workspaceFolder}/benefits/cli/main.py", "args": ["runserver", "--insecure", "0.0.0.0:8000"], "django": true, "env": { "DJANGO_DEBUG": "false", "DJANGO_STATICFILES_STORAGE": "django.contrib.staticfiles.storage.StaticFilesStorage" } + }, + { + "name": "Benefits CLI", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/benefits/cli/main.py", + "args": [], + "django": true, + "env": { + "DJANGO_DEBUG": "true", + "PYTHONWARNINGS": "default" + } } ] } diff --git a/appcontainer/Dockerfile b/appcontainer/Dockerfile index d641150c1..18b24f3eb 100644 --- a/appcontainer/Dockerfile +++ b/appcontainer/Dockerfile @@ -29,7 +29,6 @@ COPY appcontainer/proxy.conf /calitp/run/proxy.conf # copy runtime files COPY --from=build_wheel /build/dist /build/dist -COPY manage.py manage.py COPY bin bin COPY benefits benefits diff --git a/benefits/cli/__init__.py b/benefits/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benefits/cli/agency/__init__.py b/benefits/cli/agency/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benefits/cli/agency/create.py b/benefits/cli/agency/create.py new file mode 100644 index 000000000..e4a7ddca0 --- /dev/null +++ b/benefits/cli/agency/create.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass +from pathlib import Path + +from django.core.management.base import CommandError + +from benefits.cli.commands import BaseOptions, BenefitsCommand +from benefits.core.models import TransitAgency, TransitProcessor + + +@dataclass +class Options(BaseOptions): + active: bool = False + info_url: str = None + long_name: str = None + phone: str = None + short_name: str = None + slug: str = None + templates: bool = False + templates_only: bool = False + transit_processor: int = None + + def __post_init__(self): + if not self.short_name: + self.short_name = self.slug.upper() + if not self.long_name: + self.long_name = self.slug.upper() + + +class Create(BenefitsCommand): + """Create a new transit agency.""" + + help = __doc__ + name = "create" + options_cls = Options + sample_slug = "cst" + templates = [ + f"core/index--{sample_slug}.html", + f"eligibility/index--{sample_slug}.html", + ] + + @property + def template_paths(self): + return [self.template_path(t) for t in self.templates] + + def _create_agency(self, opts: Options) -> TransitAgency: + if isinstance(opts.transit_processor, int): + transit_processor = TransitProcessor.objects.get(id=opts.transit_processor) + else: + transit_processor = None + + agency = TransitAgency.objects.create( + active=opts.active, + slug=opts.slug, + info_url=opts.info_url, + long_name=opts.long_name, + phone=opts.phone, + short_name=opts.short_name, + transit_processor=transit_processor, + ) + agency.save() + + return agency + + def _create_templates(self, agency: TransitAgency): + for template in self.template_paths: + content = template.read_text().replace(self.sample_slug, agency.slug) + content = content.replace(self.sample_slug.upper(), agency.slug.upper()) + + path = str(template.resolve()).replace(self.sample_slug, agency.slug) + + new_template = Path(path) + new_template.write_text(content) + + def _raise_for_slug(self, opts: Options) -> bool: + if TransitAgency.by_slug(opts.slug): + raise CommandError(f"TransitAgency with slug already exists: {opts.slug}") + + def add_arguments(self, parser): + parser.add_argument("-a", "--active", action="store_true", default=False, help="Activate the agency") + parser.add_argument( + "-i", "--info-url", type=str, default="https://agency.com", help="The agency's informational website URL" + ) + parser.add_argument("-l", "--long-name", type=str, default="Regional Transit Agency", help="The agency's long name") + parser.add_argument("-p", "--phone", type=str, default="800-555-5555", help="The agency's phone number") + parser.add_argument("-s", "--short-name", type=str, default="Agency", help="The agency's short name") + parser.add_argument("--templates", action="store_true", default=False, help="Also create templates for the agency") + parser.add_argument( + "--templates-only", + action="store_true", + default=False, + help="Don't create the agency in the database, but scaffold templates", + ) + parser.add_argument( + "--transit-processor", + type=int, + choices=[t.id for t in TransitProcessor.objects.all()], + default=TransitProcessor.objects.first().id, + help="The id of a TransitProcessor instance to link to this agency", + ) + parser.add_argument("slug", help="The agency's slug", type=str) + + def handle(self, *args, **options): + opts = self.parse_opts(**options) + self._raise_for_slug(opts) + + if not opts.templates_only: + self.stdout.write(self.style.NOTICE("Creating new agency...")) + agency = self._create_agency(opts) + self.stdout.write(self.style.SUCCESS(f"Agency created: {agency.slug} (id={agency.id})")) + + if opts.templates: + self.stdout.write(self.style.NOTICE("Creating new agency templates...")) + self._create_templates(agency) + self.stdout.write(self.style.SUCCESS("Templates created")) diff --git a/benefits/cli/agency/list.py b/benefits/cli/agency/list.py new file mode 100644 index 000000000..974b84c2e --- /dev/null +++ b/benefits/cli/agency/list.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass + +from django.db.models import Q + +from benefits.cli.commands import BaseOptions, BenefitsCommand +from benefits.core.models import TransitAgency + + +@dataclass +class Options(BaseOptions): + all: bool = False + name: str = None + slug: str = None + + +class List(BenefitsCommand): + """List transit agencies.""" + + help = __doc__ + name = "list" + options_cls = Options + + def add_arguments(self, parser): + parser.add_argument( + "-a", + "--all", + action="store_true", + default=False, + help="Show both active and inactive agencies. By default show only active agencies.", + ) + parser.add_argument( + "-n", + "--name", + type=str, + help="Filter for agencies with matching (partial) short_name or long_name.", + ) + parser.add_argument( + "-s", + "--slug", + type=str, + help="Filter for agencies with matching (partial) slug.", + ) + + def handle(self, *args, **options): + opts = self.parse_opts(**options) + agencies = TransitAgency.objects.all() + + if not opts.all: + agencies = agencies.filter(active=True) + + if opts.name: + q = Q(short_name__contains=opts.name) | Q(long_name__contains=opts.name) + agencies = agencies.filter(q) + + if opts.slug: + agencies = agencies.filter(slug__contains=opts.slug) + + if len(agencies) > 0: + if len(agencies) > 1: + msg = f"{len(agencies)} agencies:" + else: + msg = "1 agency:" + self.stdout.write(self.style.SUCCESS(msg)) + + active = filter(lambda a: a.active, agencies) + inactive = filter(lambda a: not a.active, agencies) + + for agency in active: + self.stdout.write(self.style.HTTP_NOT_MODIFIED(f"{agency}")) + for agency in inactive: + self.stdout.write(self.style.WARNING(f"[inactive] {agency}")) + else: + self.stdout.write(self.style.HTTP_NOT_FOUND("No matching agencies")) diff --git a/benefits/cli/apps.py b/benefits/cli/apps.py new file mode 100644 index 000000000..402cc0c50 --- /dev/null +++ b/benefits/cli/apps.py @@ -0,0 +1,11 @@ +""" +The cli application: Command line interface for working with the Cal-ITP Benefits app. +""" + +from django.apps import AppConfig + + +class CoreAppConfig(AppConfig): + name = "benefits.cli" + label = "cli" + verbose_name = "Benefits CLI" diff --git a/benefits/cli/commands.py b/benefits/cli/commands.py new file mode 100644 index 000000000..1601547af --- /dev/null +++ b/benefits/cli/commands.py @@ -0,0 +1,127 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from django import template +from django.core.management.base import BaseCommand + +from benefits import VERSION + + +@dataclass +class BaseOptions: + handler: Callable = None + skip_checks: bool = False + verbosity: int = 1 + + +class BenefitsCommand(BaseCommand): + """Base class for Benefits CLI commands.""" + + name = "benefits" + help = __doc__ + options_cls = BaseOptions + subcommands = [] + version = VERSION + + def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False, default_subcmd=None): + super().__init__(stdout, stderr, no_color, force_color) + + self.commands = set() + # for each subcommand declared on the BenefitsCommand class (or subclass) + # instantiate it and add it to the commands set + self.commands.update(cmd(stdout, stderr, no_color, force_color) for cmd in self.subcommands) + # set an attribute on this instance for each command + for cmd in self.commands: + setattr(self, cmd.name, cmd) + # can be used by subclasses to track their own argument subparsers + self.subparsers = {self.name: {}} + + # default_subcmd should be one of the items in BenefitsCommand.subcommands (or subclass.subcommands) + # i.e. a class that implements one of the subcommands for this BenefitsCommand (or subclass) + if default_subcmd: + if default_subcmd in self.subcommands: + # we want to store the handle method for the instance of this default_subcmd class + # read command attribute created above + # the default handler is a function to call when the command is called without a subcommand + self.default_handler = getattr(self, default_subcmd.name).handle + else: + raise ValueError("default_subcmd must be in this Command's declared subcommands list.") + else: + self.default_handler = None + + @property + def subparser(self): + """Gets the single self.subparsers with a name matching this command's.""" + return self.subparsers[self.name] + + def add_arguments(self, parser): + """Entry point for subclassed commands to add custom arguments.""" + if len(self.commands) < 1: + return + + # For each command this BenefitsCommand instance defines + # create an argparse subparser for that command's arguments + # adapted from https://adamj.eu/tech/2024/08/14/django-management-command-sub-commands/ + command_required = self.default_handler is None + subparsers = parser.add_subparsers(title="commands", required=command_required) + + for command in self.commands: + subparser = subparsers.add_parser(command.name, help=command.help) + # command is an instance inheriting from BenefitsCommand + # so it has an .add_arguments() method + # add them to the command's subparser + command.add_arguments(subparser) + # set_defaults makes the resulting "handler" argument equal to the + # command's handle function + subparser.set_defaults(handler=command.handle) + # store a reference to each command subparser + self.subparser[command.name] = subparser + + def get_version(self) -> str: + """Override `BaseCommand.get_version()` to return the `benefits` version.""" + return self.version + + def handle(self, *args, **options): + """The actual logic of the command. Subclasses of Django's `BaseCommand` must implement this method. + + The default implementation for `BenefitsCommands` implements a pattern like: + + ``` + command --cmd-opt subcommand --subcmd-opt subcmd_arg + ``` + + Subclasses of `BenefitsCommand` may provide a custom implementation as-needed. + """ + # by default, parse options as a BaseOptions instance + opts = self.parse_opts(**options) + # get the handler parsed from the options, or the default_handler + handler = opts.handler or self.default_handler + + if handler: + if opts.verbosity > 1: + # handler is a class method, use its __self__ prop to get back to the instance + command_name = handler.__self__.name + self.stdout.write(self.style.WARNING(f"command: {command_name}")) + # call it + handler(*args, **options) + + def parse_opts(self, **options): + """Parse options into a dataclass instance.""" + options = {k: v for k, v in options.items() if k in dir(self.options_cls)} + return self.options_cls(**options) + + def template_path(self, template_name: str) -> Path: + """Get a `pathlib.Path` for the named template. + + A `template_name` is the app-local name, e.g. `enrollment/success.html`. + + Adapted from https://stackoverflow.com/a/75863472. + """ + for engine in template.engines.all(): + for loader in engine.engine.template_loaders: + for origin in loader.get_template_sources(template_name): + path = Path(origin.name) + if path.exists(): + return path + raise template.TemplateDoesNotExist(f"Could not find template: {template_name}") diff --git a/benefits/cli/main.py b/benefits/cli/main.py new file mode 100644 index 000000000..0afbd5197 --- /dev/null +++ b/benefits/cli/main.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +"""Benefits command-line utility for administrative tasks.""" +import os +import sys + +from django.core.management import execute_from_command_line + +from benefits import VERSION + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "benefits.settings") + + if len(sys.argv) == 2 and sys.argv[1] == "--version": + print(f"benefits, {VERSION}") + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/benefits/cli/management/__init__.py b/benefits/cli/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benefits/cli/management/commands/__init__.py b/benefits/cli/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/benefits/cli/management/commands/agency.py b/benefits/cli/management/commands/agency.py new file mode 100644 index 000000000..087151a17 --- /dev/null +++ b/benefits/cli/management/commands/agency.py @@ -0,0 +1,15 @@ +from benefits.cli.agency.create import Create +from benefits.cli.agency.list import List +from benefits.cli.commands import BenefitsCommand + + +class Command(BenefitsCommand): + """Work with transit agencies.""" + + help = __doc__ + name = "agency" + subcommands = [List, Create] + + def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False): + # make List the default_subcmd + super().__init__(stdout, stderr, no_color, force_color, List) diff --git a/benefits/settings.py b/benefits/settings.py index 91d79e470..e7f11241a 100644 --- a/benefits/settings.py +++ b/benefits/settings.py @@ -63,6 +63,7 @@ def RUNTIME_ENVIRONMENT(): "benefits.eligibility", "benefits.oauth", "benefits.in_person", + "benefits.cli", ] GOOGLE_SSO_CLIENT_ID = os.environ.get("GOOGLE_SSO_CLIENT_ID", "secret") diff --git a/bin/init.sh b/bin/init.sh index c2a283c2a..203fdb8f8 100755 --- a/bin/init.sh +++ b/bin/init.sh @@ -3,12 +3,12 @@ set -eux # run database migrations -python manage.py migrate +benefits migrate # generate language *.mo files for use by Django -python manage.py compilemessages +benefits compilemessages # collect static files -python manage.py collectstatic --no-input +benefits collectstatic --no-input diff --git a/bin/makemessages.sh b/bin/makemessages.sh index 48217e533..fb6a821a5 100755 --- a/bin/makemessages.sh +++ b/bin/makemessages.sh @@ -3,7 +3,7 @@ set -eu # generate initial .PO files from msgids in template and view files -python manage.py makemessages -a --no-obsolete --no-location +benefits makemessages -a --no-obsolete --no-location # put back bug-report link diff --git a/bin/makemigrations.sh b/bin/makemigrations.sh index 89d755f0b..9f04fe087 100755 --- a/bin/makemigrations.sh +++ b/bin/makemigrations.sh @@ -3,7 +3,7 @@ set -eux # generate -python manage.py makemigrations +benefits makemigrations # reformat with black diff --git a/bin/reset_db.sh b/bin/reset_db.sh index 53333a12d..515eeaaf9 100755 --- a/bin/reset_db.sh +++ b/bin/reset_db.sh @@ -18,7 +18,7 @@ if [[ $DB_RESET = true ]]; then # create a superuser account for backend admin access # set username, email, and password using environment variables # DJANGO_SUPERUSER_USERNAME, DJANGO_SUPERUSER_EMAIL, and DJANGO_SUPERUSER_PASSWORD - python manage.py createsuperuser --no-input + benefits createsuperuser --no-input else echo "DB_RESET is false, skipping" fi @@ -27,7 +27,7 @@ valid_fixtures=$(echo "$DJANGO_DB_FIXTURES" | grep -e fixtures\.json$ || test $? if [[ -n "$valid_fixtures" ]]; then # load data fixtures - python manage.py loaddata "$DJANGO_DB_FIXTURES" + benefits loaddata "$DJANGO_DB_FIXTURES" else echo "No JSON fixtures to load" fi diff --git a/docs/configuration/README.md b/docs/configuration/README.md index a3a06b17b..f41b095d4 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -35,7 +35,7 @@ import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "benefits.settings") ``` -Elsewhere, e.g. in [`manage.py`][benefits-manage], this same environment variable is set to ensure `benefits.settings` +Elsewhere, e.g. in [`cli`][benefits-cli] app, this same environment variable is set to ensure `benefits.settings` are loaded for every app command and run. ## Using configuration in app code @@ -74,7 +74,7 @@ else: # do something when this agency is inactive ``` -[benefits-manage]: https://github.com/cal-itp/benefits/blob/main/manage.py +[benefits-cli]: https://github.com/cal-itp/benefits/blob/main/benefits/cli/main.py [benefits-settings]: https://github.com/cal-itp/benefits/blob/main/benefits/settings.py [benefits-wsgi]: https://github.com/cal-itp/benefits/blob/main/benefits/wsgi.py [django-model]: https://docs.djangoproject.com/en/5.0/topics/db/models/ diff --git a/docs/development/README.md b/docs/development/README.md index 732892ace..0417693f1 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -51,19 +51,19 @@ The [environment](../configuration/environment-variables.md) can also be overrid ```jsonc { - "name": "Django: Benefits Client", - "type": "python", - "request": "launch", - "program": "${workspaceFolder}/manage.py", - "args": ["runserver", "--insecure", "0.0.0.0:8000"], - "django": true, - "env": { - // existing field... - "DJANGO_DEBUG": "true", - // add these 2 entries with the values for reCAPTCHA - "DJANGO_RECAPTCHA_SITE_KEY": "", - "DJANGO_RECAPTCHA_SECRET_KEY": "" - } + "name": "Django: Benefits Client", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/benefits/cli/main.py", + "args": ["runserver", "--insecure", "0.0.0.0:8000"], + "django": true, + "env": { + // existing field... + "DJANGO_DEBUG": "true", + // add these 2 entries with the values for reCAPTCHA + "DJANGO_RECAPTCHA_SITE_KEY": "", + "DJANGO_RECAPTCHA_SECRET_KEY": "" + } } ``` diff --git a/manage.py b/manage.py deleted file mode 100644 index b291df644..000000000 --- a/manage.py +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env python -"""Django's command-line utility for administrative tasks.""" -import os -import sys - - -def main(): - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "benefits.settings") - try: - from django.core.management import execute_from_command_line - except ImportError as exc: - raise ImportError( - "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" - ) from exc - execute_from_command_line(sys.argv) - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index a2b2080fc..690442c83 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ test = [ "pytest-socket", ] +[project.scripts] +benefits = "benefits.cli.main:main" + [project.urls] Changelog = "https://github.com/cal-itp/benefits/releases" Code = "https://github.com/cal-itp/benefits" diff --git a/tests/pytest/cli/__init__.py b/tests/pytest/cli/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pytest/cli/agency/__init__.py b/tests/pytest/cli/agency/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pytest/cli/agency/test_create.py b/tests/pytest/cli/agency/test_create.py new file mode 100644 index 000000000..f4d1e7d80 --- /dev/null +++ b/tests/pytest/cli/agency/test_create.py @@ -0,0 +1,49 @@ +import pytest + +from django.core.management.base import CommandError + +from benefits.cli.agency.create import Create +from benefits.core.models import TransitAgency + + +@pytest.fixture +def cmd(cmd): + def call(*args, **kwargs): + return cmd(Create, *args, **kwargs) + + return call + + +@pytest.mark.django_db +def test_call_no_slug(cmd): + with pytest.raises(CommandError, match="the following arguments are required: slug"): + cmd() + + +@pytest.mark.django_db +def test_call(cmd, model_TransitProcessor): + slug = "the-slug" + + agency = TransitAgency.by_slug(slug) + assert agency is None + + out, err = cmd(slug) + + assert err == "" + assert "Creating new agency" in out + assert f"Agency created: {slug}" in out + + agency = TransitAgency.by_slug(slug) + assert isinstance(agency, TransitAgency) + assert agency.transit_processor == model_TransitProcessor + + +@pytest.mark.django_db +def test_call_dupe(cmd): + slug = "the-slug" + + # first time is OK + cmd(slug) + # again with the same slug, not OK + with pytest.raises(CommandError, match=f"TransitAgency with slug already exists: {slug}"): + cmd(slug) diff --git a/tests/pytest/cli/agency/test_list.py b/tests/pytest/cli/agency/test_list.py new file mode 100644 index 000000000..766ae6fe0 --- /dev/null +++ b/tests/pytest/cli/agency/test_list.py @@ -0,0 +1,68 @@ +import pytest + +from benefits.cli.agency.list import List + + +@pytest.fixture +def cmd(cmd): + def call(*args, **kwargs): + return cmd(List, *args, **kwargs) + + return call + + +@pytest.mark.django_db +def test_call(cmd): + out, err = cmd() + + assert err == "" + assert "No matching agencies" in out + + +@pytest.mark.django_db +def test_call_one_agency(cmd, model_TransitAgency): + out, err = cmd() + + assert err == "" + assert "1 agency" in out + assert str(model_TransitAgency) in out + + +@pytest.mark.django_db +def test_call_multiple_agencies(cmd, model_TransitAgency): + orig_agency = str(model_TransitAgency) + + model_TransitAgency.pk = None + model_TransitAgency.long_name = "Another agency" + model_TransitAgency.save() + + out, err = cmd() + + assert err == "" + assert "2 agencies" in out + assert orig_agency in out + assert str(model_TransitAgency) in out + + +@pytest.mark.django_db +def test_call_active(cmd, model_TransitAgency): + orig_agency = str(model_TransitAgency) + + model_TransitAgency.pk = None + model_TransitAgency.long_name = "Another agency" + model_TransitAgency.active = False + model_TransitAgency.save() + + out, err = cmd() + + assert err == "" + assert "1 agency" in out + assert orig_agency in out + assert str(model_TransitAgency) not in out + + out, err = cmd("--all") + + assert err == "" + assert "2 agencies" in out + assert orig_agency in out + assert f"[inactive] {model_TransitAgency}" in out diff --git a/tests/pytest/cli/conftest.py b/tests/pytest/cli/conftest.py new file mode 100644 index 000000000..d39a09e4f --- /dev/null +++ b/tests/pytest/cli/conftest.py @@ -0,0 +1,17 @@ +import pytest + +from django.core.management import call_command + + +@pytest.fixture +def cmd(capfd): + def call(cls, *args, **kwargs): + call_command(cls(), *args, **kwargs) + return capfd.readouterr() + + return call + + +@pytest.fixture(autouse=True) +def db_setup(model_TransitProcessor): + pass diff --git a/tests/pytest/cli/management/__init__.py b/tests/pytest/cli/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pytest/cli/management/commands/__init__.py b/tests/pytest/cli/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/pytest/cli/management/commands/test_agency.py b/tests/pytest/cli/management/commands/test_agency.py new file mode 100644 index 000000000..ae9ee4555 --- /dev/null +++ b/tests/pytest/cli/management/commands/test_agency.py @@ -0,0 +1,42 @@ +import pytest + +from benefits.cli.agency.create import Create +from benefits.cli.agency.list import List +from benefits.cli.management.commands.agency import Command + + +@pytest.fixture +def cmd(cmd): + def call(*args, **kwargs): + return cmd(Command, *args, **kwargs) + + return call + + +@pytest.mark.django_db +def test_class(): + assert Command.help == Command.__doc__ + assert Command.name == "agency" + + assert List in Command.subcommands + assert Create in Command.subcommands + + +@pytest.mark.django_db +def test_init(): + cmd = Command() + + assert "agency" in cmd.subparsers + assert cmd.subparser == cmd.subparsers["agency"] + + list_cmd = getattr(cmd, "list") + assert isinstance(list_cmd, List) + assert cmd.default_handler == list_cmd.handle + + +@pytest.mark.django_db +def test_call(cmd): + out, err = cmd() + + assert "No matching agencies" in out + assert err == "" diff --git a/tests/pytest/cli/test_commands.py b/tests/pytest/cli/test_commands.py new file mode 100644 index 000000000..60b12b2ab --- /dev/null +++ b/tests/pytest/cli/test_commands.py @@ -0,0 +1,111 @@ +from dataclasses import dataclass + +import pytest + +from benefits import VERSION +from benefits.cli.commands import BaseOptions, BenefitsCommand + + +@dataclass +class MockOptions(BaseOptions): + str_option: str = "" + int_option: int = 0 + + +class MockCommand(BenefitsCommand): + """This is a mock command.""" + + name = "mock" + # needs to be [] for this test to avoid infinite recursion! + subcommands = [] + + +@pytest.fixture +def with_subcommand(): + # fake a class definition with subcommands + # using the above MockCommand + BenefitsCommand.subcommands.append(MockCommand) + # yield to allow the test to continue + yield + # cleanup + BenefitsCommand.subcommands.clear() + + +@pytest.fixture +def with_custom_opts(): + # fake the options_cls + # using the above MockOptions + BenefitsCommand.options_cls = MockOptions + # yield to allow the test to continue + yield + # cleanup + BenefitsCommand.options_cls = BaseOptions + + +@pytest.mark.django_db +def test_class(): + assert BenefitsCommand.help == BenefitsCommand.__doc__ + assert BenefitsCommand.name == "benefits" + assert BenefitsCommand.options_cls is BaseOptions + assert BenefitsCommand.subcommands == [] + assert BenefitsCommand.version == VERSION + + +@pytest.mark.django_db +@pytest.mark.usefixtures("with_subcommand") +def test_subcommands(): + cmd = BenefitsCommand(default_subcmd=MockCommand) + + # it should have parsed cls.subcommands into self.commands + assert len(cmd.commands) == 1 + + # get the subcommand + subcmd = list(cmd.commands).pop() + # it should be a MockCommand + assert isinstance(subcmd, MockCommand) + + # the instance + assert hasattr(cmd, MockCommand.name) + assert getattr(cmd, MockCommand.name) == subcmd + # should have a default_handler + assert cmd.default_handler == subcmd.handle + + +@pytest.mark.django_db +def test_init(): + cmd = BenefitsCommand() + + assert cmd.commands == set() + assert cmd.subparsers == {"benefits": {}} + + +@pytest.mark.django_db +def test_get_version(): + assert BenefitsCommand().get_version() == VERSION + + +@pytest.mark.django_db +def test_parse_opts(): + handler = lambda x: x # noqa: E731 + cmd = BenefitsCommand() + opts = cmd.parse_opts(handler=handler, skip_checks=True, verbosity=3) + + assert opts.handler == handler + assert opts.skip_checks + assert opts.verbosity == 3 + + +@pytest.mark.django_db +@pytest.mark.usefixtures("with_custom_opts") +def test_parse_custom_opts(): + cmd = BenefitsCommand() + + opts = cmd.parse_opts() + assert isinstance(opts, MockOptions) + assert opts.str_option == "" + assert opts.int_option == 0 + + opts = cmd.parse_opts(str_option="str", int_option=100) + assert isinstance(opts, MockOptions) + assert opts.str_option == "str" + assert opts.int_option == 100