2
0
Fork 0

feat: add wutta top-level command with make-uuid subcommand

i think it only makes sense to have an "opinion" for command line
interface in this project, and we probably need more `wutta`
subcommands too but we'll see.

main motivation for this currently is to allow poser apps to define
their own CLI, in particular e.g. `poser install`
This commit is contained in:
Lance Edgar 2024-11-23 11:48:28 -06:00
parent cb147c203d
commit 2deba45588
20 changed files with 446 additions and 47 deletions

View file

@ -0,0 +1,6 @@
``wuttjamaican.cli.base``
=========================
.. automodule:: wuttjamaican.cli.base
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.cli.make_uuid``
==============================
.. automodule:: wuttjamaican.cli.make_uuid
:members:

View file

@ -0,0 +1,6 @@
``wuttjamaican.cli``
====================
.. automodule:: wuttjamaican.cli
:members:

View file

@ -9,6 +9,9 @@
app app
auth auth
cli
cli.base
cli.make_uuid
conf conf
db db
db.conf db.conf

View file

@ -163,8 +163,8 @@ Glossary
subcommand subcommand
A top-level :term:`command` may expose one or more subcommands, A top-level :term:`command` may expose one or more subcommands,
for the overall command line interface. Subcommands are usually for the overall command line interface. Subcommands are usually
the real workhorse; each can perform a different function. See the real workhorse; each can perform a different function with a
also :doc:`narr/cli/index`. custom arg set. See also :doc:`narr/cli/index`.
virtual environment virtual environment
This term comes from the broader Python world and refers to an This term comes from the broader Python world and refers to an

View file

@ -22,8 +22,10 @@ Features
* flexible configuration, using config files and/or DB settings table * flexible configuration, using config files and/or DB settings table
* flexible architecture, abstracting various portions of the overall app * flexible architecture, abstracting various portions of the overall app
* flexible command line interface, using `Typer`_
* flexible database support, using `SQLAlchemy`_ * flexible database support, using `SQLAlchemy`_
.. _Typer: https://typer.tiangolo.com
.. _SQLAlchemy: https://www.sqlalchemy.org .. _SQLAlchemy: https://www.sqlalchemy.org
See also these projects which build on WuttJamaican: See also these projects which build on WuttJamaican:

41
docs/narr/cli/builtin.rst Normal file
View file

@ -0,0 +1,41 @@
Built-in Commands
=================
WuttJamaican comes with one top-level :term:`command`, and some
:term:`subcommands<subcommand>`.
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

View file

@ -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<command>` and :term:`subcommands<subcommand>` as
needed:
* `Typer <https://typer.tiangolo.com/>`_
* `Click <https://click.palletsprojects.com/en/latest/>`_
* :mod:`python:argparse`
For even more options see:
* `awesome-cli-framework <https://github.com/shadawck/awesome-cli-frameworks/blob/master/README.md#python>`_
* `Hitchhikers Guide to Python <https://docs.python-guide.org/scenarios/cli/>`_
* `Python Wiki <https://wiki.python.org/moin/CommandlineTools>`_
Or if that is overkill you can always just use :doc:`scripts`.

105
docs/narr/cli/custom.rst Normal file
View file

@ -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)<command>` and
:term:`subcommands<subcommand>` 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

View file

@ -2,9 +2,24 @@
Command Line Interface 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<command>` and :term:`subcommands<subcommand>`. 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:: .. toctree::
:maxdepth: 2 :maxdepth: 2
overview builtin
commands custom
scripts scripts

View file

@ -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<command>` and :term:`subcommands<subcommand>`, which
are then installed as part of a package. See :doc:`commands` for more
about that.

View file

@ -33,7 +33,7 @@ Run that like so:
Better Standards Better Standards
---------------- ~~~~~~~~~~~~~~~~
Keeping it simple, but improving that script per recommended patterns:: 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): def hello(config):
app = config.get_app() app = config.get_app()
print('hello', config.get('hello.name')) print('hello', config.get('hello.name'))
print('from', app.appname) print('from', app.get_title())
if __name__ == '__main__': if __name__ == '__main__':
config = make_config('my.conf') config = make_config('my.conf')
@ -81,7 +81,7 @@ Output should now be different:
$ python hello.py $ python hello.py
hello George hello George
from wutta from WuttJamaican
You are likely to need more imports; it is generally wise to do those 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 *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() app = config.get_app()
print('hello', config.get('hello.name')) print('hello', config.get('hello.name'))
print('from', app.appname) print('from', app.get_title())
something(config) something(config)
@ -143,7 +143,7 @@ Here is the script with logging incorporated::
log.debug("saying hello") log.debug("saying hello")
app = config.get_app() app = config.get_app()
print('hello', config.get('hello.name')) print('hello', config.get('hello.name'))
print('from', app.appname) print('from', app.get_title())
log.debug("about to do something") log.debug("about to do something")
if something(config): if something(config):

View file

@ -30,6 +30,7 @@ dependencies = [
"importlib_resources ; python_version < '3.9'", "importlib_resources ; python_version < '3.9'",
"progress", "progress",
"python-configuration", "python-configuration",
"typer",
] ]
@ -40,6 +41,10 @@ docs = ["Sphinx", "sphinxcontrib-programoutput", "enum-tools[sphinx]", "furo"]
tests = ["pytest-cov", "tox"] tests = ["pytest-cov", "tox"]
[project.scripts]
wutta = "wuttjamaican.cli:wutta_typer"
[project.urls] [project.urls]
Homepage = "https://wuttaproject.org/" Homepage = "https://wuttaproject.org/"
Repository = "https://forgejo.wuttaproject.org/wutta/wuttjamaican" Repository = "https://forgejo.wuttaproject.org/wutta/wuttjamaican"

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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"
)

View file

@ -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 <http://www.gnu.org/licenses/>.
#
################################################################################
"""
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")

0
tests/cli/__init__.py Normal file
View file

0
tests/cli/example.conf Normal file
View file

39
tests/cli/test_base.py Normal file
View file

@ -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)

View file

@ -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()