diff --git a/docs/api/wuttjamaican/cli.base.rst b/docs/api/wuttjamaican/cli.base.rst new file mode 100644 index 0000000..c937040 --- /dev/null +++ b/docs/api/wuttjamaican/cli.base.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cli.base`` +========================= + +.. automodule:: wuttjamaican.cli.base + :members: diff --git a/docs/api/wuttjamaican/cli.make_uuid.rst b/docs/api/wuttjamaican/cli.make_uuid.rst new file mode 100644 index 0000000..8350d6e --- /dev/null +++ b/docs/api/wuttjamaican/cli.make_uuid.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cli.make_uuid`` +============================== + +.. automodule:: wuttjamaican.cli.make_uuid + :members: diff --git a/docs/api/wuttjamaican/cli.rst b/docs/api/wuttjamaican/cli.rst new file mode 100644 index 0000000..c0bb811 --- /dev/null +++ b/docs/api/wuttjamaican/cli.rst @@ -0,0 +1,6 @@ + +``wuttjamaican.cli`` +==================== + +.. automodule:: wuttjamaican.cli + :members: diff --git a/docs/api/wuttjamaican/index.rst b/docs/api/wuttjamaican/index.rst index 0c9d7c0..816593d 100644 --- a/docs/api/wuttjamaican/index.rst +++ b/docs/api/wuttjamaican/index.rst @@ -9,6 +9,9 @@ app auth + cli + cli.base + cli.make_uuid conf db db.conf diff --git a/docs/glossary.rst b/docs/glossary.rst index 3b87762..805de3f 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -163,8 +163,8 @@ Glossary subcommand A top-level :term:`command` may expose one or more subcommands, for the overall command line interface. Subcommands are usually - the real workhorse; each can perform a different function. See - also :doc:`narr/cli/index`. + the real workhorse; each can perform a different function with a + custom arg set. See also :doc:`narr/cli/index`. virtual environment This term comes from the broader Python world and refers to an diff --git a/docs/index.rst b/docs/index.rst index d2a6bd4..1e9e36b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,8 +22,10 @@ Features * flexible configuration, using config files and/or DB settings table * flexible architecture, abstracting various portions of the overall app +* flexible command line interface, using `Typer`_ * flexible database support, using `SQLAlchemy`_ +.. _Typer: https://typer.tiangolo.com .. _SQLAlchemy: https://www.sqlalchemy.org See also these projects which build on WuttJamaican: diff --git a/docs/narr/cli/builtin.rst b/docs/narr/cli/builtin.rst new file mode 100644 index 0000000..0eafb2b --- /dev/null +++ b/docs/narr/cli/builtin.rst @@ -0,0 +1,41 @@ + +Built-in Commands +================= + +WuttJamaican comes with one top-level :term:`command`, and some +:term:`subcommands`. + +It uses `Typer`_ for the underlying CLI framework. + +.. _Typer: https://typer.tiangolo.com/ + + +``wutta`` +--------- + +This is the top-level command. Its purpose is to expose subcommands +pertaining to WuttJamaican. + +It is installed to the virtual environment in the ``bin`` folder (or +``Scripts`` on Windows): + +.. code-block:: sh + + cd /path/to/venv + bin/wutta --help + +Defined in: :mod:`wuttjamaican.cli` + +.. program-output:: wutta --help + + +.. _wutta-make-uuid: + +``wutta make-uuid`` +------------------- + +Print a new universally-unique identifier to standard output. + +Defined in: :mod:`wuttjamaican.cli.make_uuid` + +.. program-output:: wutta make-uuid --help diff --git a/docs/narr/cli/commands.rst b/docs/narr/cli/commands.rst deleted file mode 100644 index de73794..0000000 --- a/docs/narr/cli/commands.rst +++ /dev/null @@ -1,23 +0,0 @@ - -Commands -======== - -WuttJamaican in fact does not directly provide a way to define a -command line interface for your app. - -The reason is that several good frameworks exist already. You are -encouraged to use one of the following to define -:term:`commands` and :term:`subcommands` as -needed: - -* `Typer `_ -* `Click `_ -* :mod:`python:argparse` - -For even more options see: - -* `awesome-cli-framework `_ -* `Hitchhiker’s Guide to Python `_ -* `Python Wiki `_ - -Or if that is overkill you can always just use :doc:`scripts`. diff --git a/docs/narr/cli/custom.rst b/docs/narr/cli/custom.rst new file mode 100644 index 0000000..d893ba4 --- /dev/null +++ b/docs/narr/cli/custom.rst @@ -0,0 +1,105 @@ + +Custom Commands +=============== + +WuttJamaican comes with :doc:`/narr/cli/builtin`. + +Using the same framework, each :term:`package` can define additional +top-level :term:`command(s)` and +:term:`subcommands` as needed. + + +Top-Level Command +----------------- + +You must "define" *and* "register" your top-level command. Assuming a +basic Poser example: + +.. code-block:: none + + poser-project + ├── poser + │ ├── __init__.py + │ └── cli.py + └── pyproject.toml + +Add the command definition to the ``poser.cli`` module:: + + from wuttjamaican.cli import make_typer + + poser_typer = make_typer( + name='poser', + help="Poser - the killer app" + ) + +Then register the command as script in ``pyproject.toml``: + +.. code-block:: toml + + [project.scripts] + poser = "poser.cli:poser_typer" + +Then reinstall your project: + +.. code-block:: sh + + pip install -e ~/src/poser + +And now you can run your command: + +.. code-block:: sh + + poser --help + +But it won't really do anything until you add subcommands. + + +Subcommands +----------- + +You must "define" the subcommand of course, but do not need to +"register" it. (That happens via function decorator; see below.) + +However you *do* need to ensure all modules containing subcommands are +"eagerly imported" so the runtime discovery process finds everything. + +Here we'll define the ``poser hello`` subcommand, by adding it to our +``poser.cli`` module (from example above):: + + import sys + import typer + from wuttjamaican.cli import make_typer + + # top-level command + poser_typer = make_typer( + name='poser', + help="Poser - the killer app" + ) + + # nb. function decorator will auto-register the subcommand + @poser_typer.command() + def hello( + ctx: typer.Context, + ): + """ + Hello world example + """ + config = ctx.parent.wutta_config + app = config.get_app() + + name = config.get('hello.name', default="WhoAreYou") + sys.stdout.write(f'hello {name}\n') + + title = app.get_title() + sys.stdout.write(f'from {title}\n') + + # TODO: you may need to import other modules here, if they contain + # subcommands and would not be automatically imported otherwise. + # nb. *this* current module *is* automatically imported, only + # because of the top-level command registration in pyproject.toml + +No need to re-install, you can now use the subcommand: + +.. code-block:: sh + + poser hello --help diff --git a/docs/narr/cli/index.rst b/docs/narr/cli/index.rst index 2070234..fd45a34 100644 --- a/docs/narr/cli/index.rst +++ b/docs/narr/cli/index.rst @@ -2,9 +2,24 @@ Command Line Interface ====================== +Most apps will need some sort of command line usage, via cron or +otherwise. There are two main aspects to it: + +There is a proper CLI framework based on `Typer`_, with top-level +:term:`commands` and :term:`subcommands`. The +``wutta`` command is built-in and includes some subcommands, but each +app can define more of either as needed. Such (sub)commands are +installed as part of a :term:`package`. + +.. _Typer: https://typer.tiangolo.com + +But sometimes you just need an :term:`ad hoc script` which is a single +file and can be placed anywhere, usually *not* installed as part of a +package. + .. toctree:: :maxdepth: 2 - overview - commands + builtin + custom scripts diff --git a/docs/narr/cli/overview.rst b/docs/narr/cli/overview.rst deleted file mode 100644 index df69f9c..0000000 --- a/docs/narr/cli/overview.rst +++ /dev/null @@ -1,15 +0,0 @@ - -Overview -======== - -Many apps will need some sort of command line usage, via cron or -otherwise. There are two main aspects to it: - -First there is the :term:`ad hoc script` which is a single file and -can be placed anywhere, but is not installed as part of a -:term:`package`. See :doc:`scripts`. - -But a "true" command line interface may define -:term:`commands` and :term:`subcommands`, which -are then installed as part of a package. See :doc:`commands` for more -about that. diff --git a/docs/narr/cli/scripts.rst b/docs/narr/cli/scripts.rst index cb6cc7d..769a231 100644 --- a/docs/narr/cli/scripts.rst +++ b/docs/narr/cli/scripts.rst @@ -33,7 +33,7 @@ Run that like so: Better Standards ----------------- +~~~~~~~~~~~~~~~~ Keeping it simple, but improving that script per recommended patterns:: @@ -69,7 +69,7 @@ that this also gives you access to the :term:`app handler`:: def hello(config): app = config.get_app() print('hello', config.get('hello.name')) - print('from', app.appname) + print('from', app.get_title()) if __name__ == '__main__': config = make_config('my.conf') @@ -81,7 +81,7 @@ Output should now be different: $ python hello.py hello George - from wutta + from WuttJamaican You are likely to need more imports; it is generally wise to do those *within the function* as opposed to the top of the module. This is to @@ -97,7 +97,7 @@ before all packages are imported:: app = config.get_app() print('hello', config.get('hello.name')) - print('from', app.appname) + print('from', app.get_title()) something(config) @@ -143,7 +143,7 @@ Here is the script with logging incorporated:: log.debug("saying hello") app = config.get_app() print('hello', config.get('hello.name')) - print('from', app.appname) + print('from', app.get_title()) log.debug("about to do something") if something(config): diff --git a/pyproject.toml b/pyproject.toml index a970992..caffa3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "importlib_resources ; python_version < '3.9'", "progress", "python-configuration", + "typer", ] @@ -40,6 +41,10 @@ docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"] tests = ["pytest-cov", "tox"] +[project.scripts] +wutta = "wuttjamaican.cli:wutta_typer" + + [project.urls] Homepage = "https://wuttaproject.org/" Repository = "https://forgejo.wuttaproject.org/wutta/wuttjamaican" diff --git a/src/wuttjamaican/cli/__init__.py b/src/wuttjamaican/cli/__init__.py new file mode 100644 index 0000000..9707748 --- /dev/null +++ b/src/wuttjamaican/cli/__init__.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +WuttJamaican - command line interface + +See also :doc:`/narr/cli/index`. + +This (``wuttjamaican.cli``) namespace exposes the following: + +* :func:`~wuttjamaican.cli.base.make_typer` +* :data:`~wuttjamaican.cli.base.wutta_typer` (top-level command) +""" + +from .base import wutta_typer, make_typer + +# TODO: is this the best we can do, to register available commands? +from . import make_uuid diff --git a/src/wuttjamaican/cli/base.py b/src/wuttjamaican/cli/base.py new file mode 100644 index 0000000..05e3454 --- /dev/null +++ b/src/wuttjamaican/cli/base.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +WuttJamaican - core command logic + +See also :doc:`/narr/cli/index`. + +.. data:: wutta_typer + + This is the top-level ``wutta`` :term:`command`, using the Typer + framework. + + See also :func:`make_typer()`. +""" + +import logging +from pathlib import Path +from typing import List, Optional + +import typer +from typing_extensions import Annotated + +from wuttjamaican.conf import make_config + + +def make_cli_config(ctx: typer.Context): + """ + Make a :term:`config object` according to the command-line context + (params). + + This function is normally called by :func:`typer_callback()`. + + This function calls :func:`~wuttjamaican.conf.make_config()` using + config files specified via command line (if any). + + :param ctx: ``typer.Context`` instance + + :returns: :class:`~wuttjamaican.conf.WuttaConfig` instance + """ + logging.basicConfig() + return make_config(files=ctx.params.get('config_paths') or None) + + +def typer_callback( + ctx: typer.Context, + + config_paths: Annotated[ + Optional[List[Path]], + typer.Option('--config', '-c', + exists=True, + help="Config path (may be specified more than once)")] = None, +): + """ + Generic callback for use with top-level commands. This adds some + top-level args: + + * ``--config`` (and ``-c``) + + This callback is responsible for creating the :term:`config + object` for the command. (It calls :func:`make_cli_config()` for + that.) It then attaches it to the context as + ``ctx.wutta_config``. + """ + ctx.wutta_config = make_cli_config(ctx) + + +def make_typer(**kwargs): + """ + Create a Typer command instance, per Wutta conventions. + + This function is used to create the top-level ``wutta`` command, + :data:`wutta_typer`. You can use it to create additional + top-level commands for your app if needed. (And don't forget to + register; see :doc:`/narr/cli/custom`.) + + :param callback: Override for the ``Typer.callback`` param. If + not specified, :func:`typer_callback` is used. + + :returns: ``typer.Typer`` instance + """ + kwargs.setdefault('callback', typer_callback) + return typer.Typer(**kwargs) + + +wutta_typer = make_typer( + name='wutta', + help="Wutta Software Framework" +) diff --git a/src/wuttjamaican/cli/make_uuid.py b/src/wuttjamaican/cli/make_uuid.py new file mode 100644 index 0000000..21b9cf7 --- /dev/null +++ b/src/wuttjamaican/cli/make_uuid.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8; -*- +################################################################################ +# +# WuttJamaican -- Base package for Wutta Framework +# Copyright © 2023-2024 Lance Edgar +# +# This file is part of Wutta Framework. +# +# Wutta Framework is free software: you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# Wutta Framework is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# Wutta Framework. If not, see . +# +################################################################################ +""" +See also: :ref:`wutta-make-uuid` +""" + +import sys + +import typer + +from .base import wutta_typer + + +@wutta_typer.command() +def make_uuid( + ctx: typer.Context, +): + """ + Generate a new UUID + """ + config = ctx.parent.wutta_config + app = config.get_app() + uuid = app.make_uuid() + sys.stdout.write(f"{uuid}\n") diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/example.conf b/tests/cli/example.conf new file mode 100644 index 0000000..e69de29 diff --git a/tests/cli/test_base.py b/tests/cli/test_base.py new file mode 100644 index 0000000..214843e --- /dev/null +++ b/tests/cli/test_base.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8; -*- + +import os +from unittest import TestCase +from unittest.mock import MagicMock + +import typer + +from wuttjamaican.conf import WuttaConfig +from wuttjamaican.cli import base as mod + + +here = os.path.dirname(__file__) +example_conf = os.path.join(here, 'example.conf') + + +class TestMakeCliConfig(TestCase): + + def test_basic(self): + ctx = MagicMock(params={'config_paths': [example_conf]}) + config = mod.make_cli_config(ctx) + self.assertIsInstance(config, WuttaConfig) + self.assertEqual(config.files_read, [example_conf]) + + +class TestTyperCallback(TestCase): + + def test_basic(self): + ctx = MagicMock(params={'config_paths': [example_conf]}) + mod.typer_callback(ctx) + self.assertIsInstance(ctx.wutta_config, WuttaConfig) + self.assertEqual(ctx.wutta_config.files_read, [example_conf]) + + +class TestMakeTyper(TestCase): + + def test_basic(self): + typr = mod.make_typer() + self.assertIsInstance(typr, typer.Typer) diff --git a/tests/cli/test_make_uuid.py b/tests/cli/test_make_uuid.py new file mode 100644 index 0000000..05c5c8b --- /dev/null +++ b/tests/cli/test_make_uuid.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8; -*- + +import os +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from wuttjamaican.cli import make_uuid as mod + + +here = os.path.dirname(__file__) +example_conf = os.path.join(here, 'example.conf') + + +class TestMakeUuid(TestCase): + + def test_basic(self): + ctx = MagicMock(params={'config_paths': [example_conf]}) + with patch.object(mod, 'sys') as sys: + mod.make_uuid(ctx) + sys.stdout.write.assert_called_once()