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:
parent
cb147c203d
commit
2deba45588
6
docs/api/wuttjamaican/cli.base.rst
Normal file
6
docs/api/wuttjamaican/cli.base.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.cli.base``
|
||||
=========================
|
||||
|
||||
.. automodule:: wuttjamaican.cli.base
|
||||
:members:
|
6
docs/api/wuttjamaican/cli.make_uuid.rst
Normal file
6
docs/api/wuttjamaican/cli.make_uuid.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.cli.make_uuid``
|
||||
==============================
|
||||
|
||||
.. automodule:: wuttjamaican.cli.make_uuid
|
||||
:members:
|
6
docs/api/wuttjamaican/cli.rst
Normal file
6
docs/api/wuttjamaican/cli.rst
Normal file
|
@ -0,0 +1,6 @@
|
|||
|
||||
``wuttjamaican.cli``
|
||||
====================
|
||||
|
||||
.. automodule:: wuttjamaican.cli
|
||||
:members:
|
|
@ -9,6 +9,9 @@
|
|||
|
||||
app
|
||||
auth
|
||||
cli
|
||||
cli.base
|
||||
cli.make_uuid
|
||||
conf
|
||||
db
|
||||
db.conf
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
41
docs/narr/cli/builtin.rst
Normal file
41
docs/narr/cli/builtin.rst
Normal 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
|
|
@ -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>`_
|
||||
* `Hitchhiker’s 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
105
docs/narr/cli/custom.rst
Normal 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
|
|
@ -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<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::
|
||||
:maxdepth: 2
|
||||
|
||||
overview
|
||||
commands
|
||||
builtin
|
||||
custom
|
||||
scripts
|
||||
|
|
|
@ -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.
|
|
@ -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):
|
||||
|
|
|
@ -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"
|
||||
|
|
37
src/wuttjamaican/cli/__init__.py
Normal file
37
src/wuttjamaican/cli/__init__.py
Normal 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
|
108
src/wuttjamaican/cli/base.py
Normal file
108
src/wuttjamaican/cli/base.py
Normal 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"
|
||||
)
|
44
src/wuttjamaican/cli/make_uuid.py
Normal file
44
src/wuttjamaican/cli/make_uuid.py
Normal 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
0
tests/cli/__init__.py
Normal file
0
tests/cli/example.conf
Normal file
0
tests/cli/example.conf
Normal file
39
tests/cli/test_base.py
Normal file
39
tests/cli/test_base.py
Normal 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)
|
20
tests/cli/test_make_uuid.py
Normal file
20
tests/cli/test_make_uuid.py
Normal 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()
|
Loading…
Reference in a new issue