From 6de3956dc61777976f85649e52b68060b9895f60 Mon Sep 17 00:00:00 2001 From: Shawn Davis Date: Thu, 6 Apr 2023 23:53:50 -0500 Subject: [PATCH] Updated the CLI interface. --- Makefile | 5 + RELEASE.txt | 2 +- docs/source/reference.rst | 75 ++- help/docs/commands/django.md | 114 ++-- help/docs/index.md | 35 +- help/docs/profiles/centos.md | 17 +- help/docs/profiles/ubuntu.md | 17 +- help/mkdocs.yml | 25 +- meta.ini | 8 +- sandbox/cli.py | 382 ------------- sandbox/tease.py | 7 +- scripttease/cli/__init__.py | 540 ++---------------- scripttease/cli/initialize.py | 456 +++++++++++---- scripttease/cli/subcommands.py | 210 ++++--- scripttease/data/inventory/nextcloud/meta.ini | 6 + scripttease/data/inventory/pgsql/meta.ini | 6 + scripttease/data/inventory/radicale/steps.ini | 8 +- scripttease/data/inventory/ubuntu/meta.ini | 6 + scripttease/lib/commands/base.py | 17 +- scripttease/lib/commands/django.py | 82 ++- scripttease/lib/commands/posix.py | 1 + scripttease/lib/commands/ubuntu.py | 2 +- scripttease/lib/factories.py | 2 +- scripttease/lib/loaders/ini.py | 1 + setup.py | 6 +- tests/examples/users.ini | 9 + tests/test_lib_commands.py | 9 + tests/test_lib_commands_base.py | 52 +- tests/test_lib_commands_django.py | 27 +- 29 files changed, 910 insertions(+), 1217 deletions(-) delete mode 100755 sandbox/cli.py create mode 100644 scripttease/data/inventory/nextcloud/meta.ini create mode 100644 scripttease/data/inventory/pgsql/meta.ini create mode 100644 scripttease/data/inventory/ubuntu/meta.ini create mode 100644 tests/examples/users.ini create mode 100644 tests/test_lib_commands.py diff --git a/Makefile b/Makefile index d863e54..9034231 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,11 @@ PROJECT_NAME := python-scripttease # The directory where test coverage is generated. COVERAGE_PATH := docs/build/html/coverage +# $(file) may not work depending upon version of make. +#RELEASE := $(strip $(file < RELEASE.txt)) +RELEASE := $(strip `cat RELEASE.txt`) +VERSION := $(strip `cat VERSION.txt`) + # Attempt to load a local makefile which may override any of the values above. -include local.makefile diff --git a/RELEASE.txt b/RELEASE.txt index c41eb10..1496c17 100644 --- a/RELEASE.txt +++ b/RELEASE.txt @@ -1 +1 @@ -7.0.0-a +7.0.0-a \ No newline at end of file diff --git a/docs/source/reference.rst b/docs/source/reference.rst index 607c59e..120741b 100644 --- a/docs/source/reference.rst +++ b/docs/source/reference.rst @@ -18,18 +18,15 @@ Library Commands -------- -.. automodule:: scripttease.library.commands.base +.. automodule:: scripttease.lib.commands.base :members: :show-inheritance: :special-members: __init__ -Overlays --------- - -Common +Centos ...... -.. automodule:: scripttease.library.overlays.common +.. automodule:: scripttease.lib.commands.centos :members: :show-inheritance: :special-members: __init__ @@ -37,7 +34,31 @@ Common Django ...... -.. automodule:: scripttease.library.overlays.django +.. automodule:: scripttease.lib.commands.django + :members: + :show-inheritance: + :special-members: __init__ + +Messages +........ + +.. automodule:: scripttease.lib.commands.messages + :members: + :show-inheritance: + :special-members: __init__ + +MySQL +...... + +.. automodule:: scripttease.lib.commands.mysql + :members: + :show-inheritance: + :special-members: __init__ + +PHP +... + +.. automodule:: scripttease.lib.commands.php :members: :show-inheritance: :special-members: __init__ @@ -45,7 +66,7 @@ Django Postgres ........ -.. automodule:: scripttease.library.overlays.pgsql +.. automodule:: scripttease.lib.commands.pgsql :members: :show-inheritance: :special-members: __init__ @@ -53,7 +74,15 @@ Postgres Posix ..... -.. automodule:: scripttease.library.overlays.posix +.. automodule:: scripttease.lib.commands.posix + :members: + :show-inheritance: + :special-members: __init__ + +Python +...... + +.. automodule:: scripttease.lib.commands.python :members: :show-inheritance: :special-members: __init__ @@ -61,34 +90,34 @@ Posix Ubuntu ...... -.. automodule:: scripttease.library.overlays.ubuntu +.. automodule:: scripttease.lib.commands.ubuntu :members: :show-inheritance: :special-members: __init__ -Scripts -------- +Contexts +======== -.. automodule:: scripttease.library.scripts +.. automodule:: scripttease.contexts :members: :show-inheritance: :special-members: __init__ -Factory -======= +Factories +========= -.. automodule:: scripttease.factory +.. automodule:: scripttease.factories :members: :show-inheritance: :special-members: __init__ -Parsers +Loaders ======= Base ---- -.. automodule:: scripttease.parsers.base +.. automodule:: scripttease.lib.loaders.base :members: :show-inheritance: :special-members: __init__ @@ -96,15 +125,7 @@ Base Config (INI) ------------ -.. automodule:: scripttease.parsers.ini - :members: - :show-inheritance: - :special-members: __init__ - -Utils ------ - -.. automodule:: scripttease.parsers.utils +.. automodule:: scripttease.lib.loaders.ini :members: :show-inheritance: :special-members: __init__ diff --git a/help/docs/commands/django.md b/help/docs/commands/django.md index 1c0a72a..6bcdf59 100644 --- a/help/docs/commands/django.md +++ b/help/docs/commands/django.md @@ -4,29 +4,31 @@ Summary: Work with Django management commands. ## Common Options for Django Commands -You will generally want to include `cd` to change to the project directory and `prefix` to load the virtual environment. +You will want to include `cd` to change to the project directory (where `manage.py` lives) and supply `venv` to load the virtual environment. + +```ini +[collect static files] +django.static: +cd: /path/to/project/source +venv: ../python -```yaml -- collect static files: - django: static - cd: /path/to/project/source - prefix: source ../python/bin/activate ``` ## Automatic Conversion of Django Command Switches Options provided in the command configuration file are automatically converted to command line switches. -```yaml -- run database migrations: - django: migrate - settings: tenants.example_com.settings +```ini +[run database migrations] +django.migrate: +settings: tenants.example_com.settings + +[dump some data] +django.dump: path/to/dump.json +indent: 4 +natural_foreign: yes +natural_primary: yes -- dump some data: - django: dumpdata - indent: 4 - natural_foreign: yes - natural_primary: yes ``` ## Available Commands @@ -35,66 +37,77 @@ Options provided in the command configuration file are automatically converted t ```ini [run django checks] -django: check +django.check: +stop: yes + +``` + +### collectstatic + +Alias: static + +Collect static files. + +```ini +[collect static files] +django.static: ``` -```yaml -- run django checks: - django: check +### createsuperuser + +Create a superuser account. + +```ini +[create the root user account] +django.createsuperuser: root +email: root@example.com ``` ### dumpdata +Alias: dump + Dump fixture data. -- app: Required. The name of the app. -- model: Optional. A model name within the app. -- path: The path to the JSON file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`. +- target (str): Required. The name of the app or `app.Model`. +- format (str): `json` (default) or `xml`. +- path (str): The path to the output file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`. ```ini [dump project data] -django: dumpdata -app: projects +django.dump: projects [dump project categories] -django: dumpdata -app: projects -model: Category +django.dump: projects.Category path: local/projects/fixtures/default-categories.json + ``` ### loaddata -Load fixture data. +Alias: load -- path: The path to the JSON file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`. - -### migrate +Load fixture data. -Run database migrations. +- target (str): Required. The name of the app or `app.Model`. +- format (str): `json` (default) or `xml` +- path (str): The path to the JSON file. When a model is provided, this defaults to `fixtures/app/model.json`. Otherwise, it is `fixtures/app/initial.json`. ```ini -[run database migrations] -django: migrate -``` +[load project categories] +django.load: projects +path: local/projects/fixtures/default-categories.json -```yaml -- run database migrations: - django: migrate ``` -### static +### migrate -Collect static files. +Run database migrations. ```ini -[collect static files] -django: static -``` - -```yaml -- collect static files: - django: static +[run database migrations] +django.migrate: +stop: yes ``` ## Custom or Ad Hoc Commands @@ -108,11 +121,4 @@ first_option_name: asdf second_option_name: 1234 third_option_name: yes ``` - -```yaml -- run any django command: - django: command_name - first_option_name: asdf - second_option_name: 1234 - third_option_name: yes ``` diff --git a/help/docs/index.md b/help/docs/index.md index 0e9f4ad..27953ef 100644 --- a/help/docs/index.md +++ b/help/docs/index.md @@ -6,14 +6,18 @@ Script Tease is a library and command line tool for generating Bash commands pro The primary focus (and limit) is to convert plain text instructions (in INI or YAML format) into valid command line statements for a given platform. It does *not* provide support for executing those statements. +!!! warning + YAML support is untested. + ## Concepts ### Command Generation -Script Tease may be used in two (2) ways: +Script Tease may be used in three (3) ways: 1. Using the library to programmatically define commands and export them as command line statements. 2. Using the `tease` command to generate commands from a configuration file. See [command file configuration](config/command-file.md). +3. Using the command file format and library to create a custom implementation. This documentation focuses on the second method, but the developer docs may be used in your own implementation. @@ -21,29 +25,30 @@ This documentation focuses on the second method, but the developer docs may be u The format of INI and YAML files is self-documenting. The command comment is this section (INI) or start of a list item (YAML). This ensures that all commands have a basic description of their purpose or intent. -### Snippets - -An *snippet* is simply a tokenized command that may be customized based on the instructions found in a command file. Related snippets are collected into groups and then merged into a larger set that define the capabilities of a specific operating system. +```ini +[install apache] +install: apache2 -!!! note - At present, the only fully defined operating systems are for Cent OS and Ubuntu. +``` -Snippets are defined in Python dictionaries. These include a "canonical" command name as the key and either a string or list which define the command. In both cases, the contents are parsed as Jinja templates. There are various approaches to evaluating a snippet. +```yaml +- install apache + install: apache2 -First: The snippet is a simple mapping of command name and command snippet. This is easy. Find the command name in the dictionary, and we have the snippet to be used. For example the `append` command in the `posix` dictionary. +``` -Second: The snippet is a mapping of command name and a list of snippets to be combined. Find the command name in the dictionary, and iterate through the snippets. For example, many of the commands in the `posix` dictionary takes this form. Command identification is the same as the first condition. +### Representing Commands -Third: The command is a mapping to informal sub-commands. Examples include `apache` and `system` in the `ubuntu` dictionary. There are a couple of ways to handle this in the config file: +All commands are represented by simple, Python functions. These functions are responsible for accepting the arguments provided (usually by a command loader) and converting them into a common `Command` instance. This instance is then capable of generating a finished statement that may be used on the command line. -- Use the outer command as the command with the inner command as the first (and perhaps only) argument. For example `apache: reload` or `system: upgrade`. -- Use a "dotted path" to find the command. For example: `apache.reload: (implicity True)` or `system.upgrade: (implicitly True)`. Or `apache.enable_site: example.com`. +#### Profiles -The first approach complicates things when detecting actual sub-commands (below). Script Tease supports both of these approaches. +Profiles contain command functions that are specific to an operating system. Not all operating systems support the same commands. -Fourth: The command also expects a sub-command. In some cases, the sub-command may be implicit, like `pip install`. In other cases, a number of sub-commands may be pre-defined, but ad hoc sub-commands should also be supported as with Django commands. +!!! note + At present, the only fully defined operating systems are for Cent OS and Ubuntu. -Fifth: Builds upon the third and fourth conditions where the commands have lots of options, some of which may be defined at runtime. Postgres and MySQL may use be presented as informal sub-commands, but there are lots of options and challenges in building the final command. Django management commands have a number of standard options, specific options, and must also support ad hoc commands. +Profiles import and appropriate all other available commands. ## Terms and Definitions diff --git a/help/docs/profiles/centos.md b/help/docs/profiles/centos.md index fc14fff..5f9aab1 100644 --- a/help/docs/profiles/centos.md +++ b/help/docs/profiles/centos.md @@ -76,11 +76,11 @@ stop: postgresql ### system -With with the system. +Work with the system. -- `system.reboot` -- `system.update` -- `system.upgrade` +- `reboot` +- `update` +- `upgrade` ### uninstall @@ -111,14 +111,17 @@ Create a user: ```ini [create the deploy user] -user.add: deploy -groups: www-data +user: deploy +groups: sudo, www-data home: /var/www +sudo: yes ``` Remove a user: ```ini [remove bob] -user.remove: bob +user: bob +op: remove +sudo: yes ``` diff --git a/help/docs/profiles/ubuntu.md b/help/docs/profiles/ubuntu.md index 299313f..ae64fa0 100644 --- a/help/docs/profiles/ubuntu.md +++ b/help/docs/profiles/ubuntu.md @@ -76,11 +76,11 @@ stop: postgresql ### system -With with the system. +Work with the system. -- `system.reboot` -- `system.update` -- `system.upgrade` +- `reboot` +- `update` +- `upgrade` ### uninstall @@ -111,14 +111,17 @@ Create a user: ```ini [create the deploy user] -user.add: deploy -groups: www-data +user: deploy +groups: sudo, www-data home: /var/www +sudo: yes ``` Remove a user: ```ini [remove bob] -user.remove: bob +user: bob +op: remove +sudo: yes ``` diff --git a/help/mkdocs.yml b/help/mkdocs.yml index 3a906e2..fbfff79 100644 --- a/help/mkdocs.yml +++ b/help/mkdocs.yml @@ -6,6 +6,7 @@ markdown_extensions: - admonition - attr_list - def_list + - pymdownx.superfences nav: - Home: index.md - Configuration: @@ -13,14 +14,30 @@ nav: - Variables: config/variables.md - Commands: - Django: commands/django.md - - Messages: commands/messages.md - MySQL: commands/mysql.md + - Messages: commands/messages.md - PHP: commands/php.md - Postgres: commands/pgsql.md - POSIX: commands/posix.md - Python: commands/python.md - Profiles: + - CentOS: profiles/centos.md - Ubuntu: profiles/ubuntu.md -# - Developer Reference: /developers/ -repo_name: GitLab -repo_url: https://git.sixgunsoftware.com/python-scripttease +repo_name: Git Traction +repo_url: https://gittraction.com/diff6/python-scripttease +theme: + name: material + palette: + + # Palette toggle for light mode + - scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + + # Palette toggle for dark mode + - scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + diff --git a/meta.ini b/meta.ini index 1770ea9..30459f0 100644 --- a/meta.ini +++ b/meta.ini @@ -1,9 +1,15 @@ [project] category = developer -description = A collection of classes and commands for automated command line scripting using Pythonn. +description = A collection of classes and commands for automated command line scripting using Python. +icon = fas fa-scroll title = Python Script Tease type = cli [business] code = PTL name = Pleasant Tents, LLC + +[license] +code = bsd3 +name = 3-Clause BSD +url = https://opensource.org/licenses/BSD-3-Clause diff --git a/sandbox/cli.py b/sandbox/cli.py deleted file mode 100755 index 5108db6..0000000 --- a/sandbox/cli.py +++ /dev/null @@ -1,382 +0,0 @@ -#! /usr/bin/env python - -from argparse import ArgumentParser, RawDescriptionHelpFormatter -from commonkit import highlight_code, indent, smart_cast, write_file -from commonkit.logging import LoggingHelper -from commonkit.shell import EXIT -from markdown import markdown -import os -import sys - -sys.path.insert(0, "../") - -from scripttease.constants import LOGGER_NAME -from scripttease.lib.contexts import Context -from scripttease.lib.loaders import load_variables, INILoader, YMLLoader -from scripttease.variables import PATH_TO_SCRIPT_TEASE -from scripttease.version import DATE as VERSION_DATE, VERSION - -DEBUG = 10 - -logging = LoggingHelper(colorize=True, name=LOGGER_NAME) -log = logging.setup() - - -def execute(): - """Process script configurations.""" - - __author__ = "Shawn Davis " - __date__ = VERSION_DATE - __help__ = """NOTES - -This command is used to parse configuration files and output the commands. - -""" - __version__ = VERSION + "+new" - - # Main argument parser from which sub-commands are created. - parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter) - - parser.add_argument( - "path", - default="steps.ini", - nargs="?", - help="The path to the configuration file." - ) - - parser.add_argument( - "-c", - "--color", - action="store_true", - dest="color_enabled", - help="Enable code highlighting for terminal output." - ) - - parser.add_argument( - "-C=", - "--context=", - action="append", - dest="variables", - help="Context variables for use in pre-parsing the config and templates. In the form of: name:value" - ) - - parser.add_argument( - "-d=", - "--docs=", - choices=["html", "markdown", "plain", "rst"], - # default="markdown", - dest="docs", - help="Output documentation instead of code." - ) - - parser.add_argument( - "-D", - "--debug", - action="store_true", - dest="debug_enabled", - help="Enable debug output." - ) - - parser.add_argument( - "-f=", - "--filter=", - action="append", - dest="filters", - help="Filter the commands in the form of: attribute:value" - ) - - parser.add_argument( - "-i=", - "--inventory=", - dest="inventory", - help="Copy an inventory item to a local directory." - ) - - parser.add_argument( - "-o=", - "--option=", - action="append", - dest="options", - help="Common command options in the form of: name:value" - ) - - parser.add_argument( - "-P=", - "--profile=", - choices=["centos", "ubuntu"], - default="ubuntu", - dest="profile", - help="The OS profile to use." - ) - - parser.add_argument( - "-T=", - "--template-path=", - action="append", - dest="template_locations", - help="The location of template files that may be used with the template command." - ) - - parser.add_argument( - "-w=", - "--write=", - dest="output_file", - help="Write the output to disk." - ) - - parser.add_argument( - "-V=", - "--variables-file=", - dest="variables_file", - help="Load variables from a file." - ) - - # Access to the version number requires special consideration, especially - # when using sub parsers. The Python 3.3 behavior is different. See this - # answer: http://stackoverflow.com/questions/8521612/argparse-optional-subparser-for-version - parser.add_argument( - "-v", - action="version", - help="Show version number and exit.", - version=__version__ - ) - - parser.add_argument( - "--version", - action="version", - help="Show verbose version information and exit.", - version="%(prog)s" + " %s %s by %s" % (__version__, __date__, __author__) - ) - - # Parse arguments. - args = parser.parse_args() - - if args.debug_enabled: - log.setLevel(DEBUG) - - log.debug("Namespace: %s" % args) - - # Create the global context. - context = Context() - - if args.variables_file: - variables = load_variables(args.variables_file) - for v in variables: - context.variables[v.name] = v - - if args.variables: - for token in args.variables: - try: - key, value = token.split(":") - context.add(key, smart_cast(value)) - except ValueError: - context.add(token, True) - - # Capture filters. - if args.filters: - filters = dict() - for token in args.filters: - key, value = token.split(":") - if key not in filters: - filters[key] = list() - - filters[key].append(value) - - # Handle global command options. - options = dict() - if args.options: - for token in args.options: - try: - key, value = token.split(":") - options[key] = smart_cast(value) - except ValueError: - options[token] = True - - # The path may have been given as a file name (steps.ini), path, or an inventory name. - input_locations = [ - args.path, - os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.ini"), - os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.yml"), - ] - path = None - for location in input_locations: - if os.path.exists(location): - path = location - break - - if path is None: - log.warning("Path does not exist: %s" % args.path) - exit(EXIT.INPUT) - - # Load the commands. - if path.endswith(".ini"): - loader = INILoader( - path, - context=context, - locations=args.template_locations, - profile=args.profile, - **options - ) - elif path.endswith(".yml"): - loader = YMLLoader( - path, - context=context, - locations=args.template_locations, - profile=args.profile, - **options - ) - else: - log.error("Unsupported file format: %s" % path) - exit(EXIT.ERROR) - - # noinspection PyUnboundLocalVariable - if not loader.load(): - log.error("Failed to load the input file: %s" % path) - exit(EXIT.ERROR) - - # Validate snippets before continuing. - valid = list() - for snippet in loader.get_snippets(): - if snippet.is_valid: - valid.append(True) - else: - log.error("Invalid snippet: %s" % snippet.name) - valid.append(False) - - if not all(valid): - exit(EXIT.ERROR) - - # Generate output. - if args.docs: - output = list() - for snippet in loader.get_snippets(): - - # Will this every happen? - # if snippet is None: - # continue - - if snippet.name == "explain": - if snippet.header: - if args.docs == "plain": - output.append("***** %s *****" % snippet.name.title()) - elif args.docs == "rst": - output.append(snippet.name.title()) - output.append("=" * len(snippet.name)) - else: - output.append("## %s" % snippet.name.title()) - - output.append("") - - output.append(snippet.content) - output.append("") - elif snippet.name == "screenshot": - if args.docs == "html": - b = list() - b.append('") - output.append("") - elif args.docs == "plain": - output.append(snippet.args[0]) - output.append("") - elif args.docs == "rst": - output.append(".. figure:: %s" % snippet.args[0]) - - if snippet.caption: - output.append(indent(":alt: %s" % snippet.caption or snippet.comment)) - - if snippet.height: - output.append(indent(":height: %s" % snippet.height)) - - if snippet.width: - output.append(indent(":width: %s" % snippet.width)) - - output.append("") - else: - output.append("![%s](%s)" % (snippet.caption or snippet.comment, snippet.args[0])) - output.append("") - elif snippet.name == "template": - if args.docs == "plain": - output.append("+++") - output.append(snippet.get_content()) - output.append("+++") - elif args.docs == "rst": - output.append(".. code-block:: %s" % snippet.get_target_language()) - output.append("") - output.append(indent(snippet.get_content())) - output.append("") - else: - output.append("```%s" % snippet.get_target_language()) - output.append(snippet.get_content()) - output.append("```") - output.append("") - else: - statement = snippet.get_statement(include_comment=False, include_register=False, include_stop=False) - if statement is not None: - line = snippet.comment.replace("#", "") - output.append("%s:" % line.capitalize()) - output.append("") - if args.docs == "plain": - output.append("---") - output.append(statement) - output.append("---") - output.append("") - elif args.docs == "rst": - output.append(".. code-block:: bash") - output.append("") - output.append(indent(statement)) - output.append("") - else: - output.append("```bash") - output.append(statement) - output.append("```") - output.append("") - - if args.docs == "html": - _output = markdown("\n".join(output), extensions=['fenced_code']) - else: - _output = "\n".join(output) - - print(_output) - - if args.output_file: - write_file(args.output_file, _output) - else: - commands = list() - for snippet in loader.get_snippets(): - # Explanations and screenshots don't produce usable statements but may be added as comments. - if snippet.name in ("explain", "screenshot"): - # commands.append("# %s" % snippet.content) - # commands.append("") - continue - - statement = snippet.get_statement() - if statement is not None: - commands.append(statement) - commands.append("") - - if args.color_enabled: - print(highlight_code("\n".join(commands), language="bash")) - else: - print("\n".join(commands)) - - if args.output_file: - write_file(args.output_file, "\n".join(commands)) - - exit(EXIT.OK) - - -if __name__ == '__main__': - execute() - - diff --git a/sandbox/tease.py b/sandbox/tease.py index ea53983..89c9be4 100755 --- a/sandbox/tease.py +++ b/sandbox/tease.py @@ -5,10 +5,9 @@ import sys sys.path.insert(0, "../") -from scripttease.cli import execute +from scripttease.cli import main_command if __name__ == '__main__': sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) - sys.exit(execute()) - # old: - # sys.exit(main_command()) + sys.exit(main_command()) + diff --git a/scripttease/cli/__init__.py b/scripttease/cli/__init__.py index fdc9f83..d7e6d52 100644 --- a/scripttease/cli/__init__.py +++ b/scripttease/cli/__init__.py @@ -1,22 +1,15 @@ # Imports from argparse import ArgumentParser, RawDescriptionHelpFormatter - from commonkit.logging import LoggingHelper -from ..variables import LOGGER_NAME, PATH_TO_SCRIPT_TEASE +from commonkit.shell import EXIT +from ..lib.contexts import Context +from ..lib.loaders import load_variables +from ..variables import LOGGER_NAME from ..version import DATE as VERSION_DATE, VERSION from . import initialize from . import subcommands -# New: -from commonkit import highlight_code, indent, smart_cast, write_file -from commonkit.shell import EXIT -from markdown import markdown -import os -from scripttease.lib.contexts import Context -from scripttease.lib.factories import command_factory -from scripttease.lib.loaders import load_variables, INILoader, YMLLoader - DEBUG = 10 logging = LoggingHelper(colorize=True, name=LOGGER_NAME) @@ -25,7 +18,7 @@ log = logging.setup() # Commands -def execute(): +def main_command(): """Process script configurations.""" __author__ = "Shawn Davis " @@ -40,99 +33,14 @@ def execute(): # Main argument parser from which sub-commands are created. parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter) - parser.add_argument( - "path", - default="steps.ini", - nargs="?", - help="The path to the configuration file." - ) - - parser.add_argument( - "-c", - "--color", - action="store_true", - dest="color_enabled", - help="Enable code highlighting for terminal output." - ) - - parser.add_argument( - "-C=", - "--context=", - action="append", - dest="variables", - help="Context variables for use in pre-parsing the config and templates. In the form of: name:value" - ) - - parser.add_argument( - "-d=", - "--docs=", - choices=["html", "markdown", "plain", "rst"], - # default="markdown", - dest="docs", - help="Output documentation instead of code." - ) - - parser.add_argument( - "-D", - "--debug", - action="store_true", - dest="debug_enabled", - help="Enable debug output." - ) - - parser.add_argument( - "-f=", - "--filter=", - action="append", - dest="filters", - help="Filter the commands in the form of: attribute:value" - ) - - parser.add_argument( - "-i=", - "--inventory=", - dest="inventory", - help="Copy an inventory item to a local directory." - ) - - parser.add_argument( - "-o=", - "--option=", - action="append", - dest="options", - help="Common command options in the form of: name:value" + # Initialize sub-commands. + subparsers = parser.add_subparsers( + dest="subcommand", + help="Commands", + metavar="docs, inventory, script" ) - parser.add_argument( - "-P=", - "--profile=", - choices=["centos", "ubuntu"], - default="ubuntu", - dest="profile", - help="The OS profile to use." - ) - - parser.add_argument( - "-T=", - "--template-path=", - action="append", - dest="template_locations", - help="The location of template files that may be used with the template command." - ) - - parser.add_argument( - "-w=", - "--write=", - dest="output_file", - help="Write the output to disk." - ) - - parser.add_argument( - "-V=", - "--variables-file=", - dest="variables_file", - help="Load variables from a file." - ) + initialize.subcommands(subparsers) # Access to the version number requires special consideration, especially # when using sub parsers. The Python 3.3 behavior is different. See this @@ -158,404 +66,54 @@ def execute(): log.setLevel(DEBUG) log.debug("Namespace: %s" % args) - - # Create the global context. - context = Context() - if args.variables_file: - variables = load_variables(args.variables_file) - for v in variables: - context.variables[v.name] = v + # Load resources for docs and script output. + if args.subcommand in ["docs", "script"]: + # Create the global context. + context = Context() - if args.variables: - for token in args.variables: - try: - key, value = token.split(":") - context.add(key, smart_cast(value)) - except ValueError: - context.add(token, True) + if args.variables_file: + variables = load_variables(args.variables_file) + for v in variables: + context.variables[v.name] = v - # Capture filters. - if args.filters: - filters = dict() - for token in args.filters: - key, value = token.split(":") - if key not in filters: - filters[key] = list() + if args.variables: + initialize.variables_from_cli(context, args.variables) - filters[key].append(value) + # Handle global command options. + options = dict() + if args.options: + options = initialize.options_from_cli(args.options) - # Handle global command options. - options = dict() - if args.options: - for token in args.options: - try: - key, value = token.split(":") - options[key] = smart_cast(value) - except ValueError: - options[token] = True - - # The path may have been given as a file name (steps.ini), path, or an inventory name. - input_locations = [ - args.path, - os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.ini"), - os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", args.path, "steps.yml"), - ] - path = None - for location in input_locations: - if os.path.exists(location): - path = location - break - - if path is None: - log.warning("Path does not exist: %s" % args.path) - exit(EXIT.INPUT) - - # Load the commands. - if path.endswith(".ini"): - loader = INILoader( - path, - context=context, - locations=args.template_locations, - profile=args.profile, - **options - ) - elif path.endswith(".yml"): - loader = YMLLoader( - path, + loader = initialize.loader( + args.command_file, context=context, - locations=args.template_locations, - profile=args.profile, - **options + options=options, + template_locations=args.template_locations ) - else: - log.error("Unsupported file format: %s" % path) - exit(EXIT.ERROR) - - # noinspection PyUnboundLocalVariable - if not loader.load(): - log.error("Failed to load the input file: %s" % path) - exit(EXIT.ERROR) - - # Generate output. - if args.docs: - output = list() - for command in loader.commands: - - # Will this every happen? - # if command is None: - # continue - - if command.name == "explain": - if command.header: - if args.docs == "plain": - output.append("***** %s *****" % command.name.title()) - elif args.docs == "rst": - output.append(command.name.title()) - output.append("=" * len(command.name)) - else: - output.append("## %s" % command.name.title()) - - output.append("") - - output.append(command.content) - output.append("") - elif command.name == "screenshot": - if args.docs == "html": - b = list() - b.append('") - output.append("") - elif args.docs == "plain": - output.append(command.args[0]) - output.append("") - elif args.docs == "rst": - output.append(".. figure:: %s" % command.args[0]) - - if command.caption: - output.append(indent(":alt: %s" % command.caption or command.comment)) - - if command.height: - output.append(indent(":height: %s" % command.height)) - - if command.width: - output.append(indent(":width: %s" % command.width)) - - output.append("") - else: - output.append("![%s](%s)" % (command.caption or command.comment, command.args[0])) - output.append("") - elif command.name == "template": - if args.docs == "plain": - output.append("+++") - output.append(command.get_content()) - output.append("+++") - elif args.docs == "rst": - output.append(".. code-block:: %s" % command.get_target_language()) - output.append("") - output.append(indent(command.get_content())) - output.append("") - else: - output.append("```%s" % command.get_target_language()) - output.append(command.get_content()) - output.append("```") - output.append("") - else: - statement = command.get_statement(include_comment=False, include_register=False, include_stop=False) - if statement is not None: - line = command.comment.replace("#", "") - output.append("%s:" % line.capitalize()) - output.append("") - if args.docs == "plain": - output.append("---") - output.append(statement) - output.append("---") - output.append("") - elif args.docs == "rst": - output.append(".. code-block:: bash") - output.append("") - output.append(indent(statement)) - output.append("") - else: - output.append("```bash") - output.append(statement) - output.append("```") - output.append("") - - if args.docs == "html": - _output = markdown("\n".join(output), extensions=['fenced_code']) - else: - _output = "\n".join(output) - - print(_output) - - if args.output_file: - write_file(args.output_file, _output) - else: - commands = command_factory(loader) - output = list() - for command in commands: - # print("COMMAND", command) - # Explanations and screenshots don't produce usable statements but may be added as comments. - if command.name in ("explain", "screenshot"): - # commands.append("# %s" % command.content) - # commands.append("") - continue - - statement = command.get_statement(include_comment=False) - if statement is not None: - output.append(statement) - output.append("") - - if args.color_enabled: - print(highlight_code("\n".join(output), language="bash")) - else: - print("\n".join(output)) - - if args.output_file: - write_file(args.output_file, "\n".join(output)) - - exit(EXIT.OK) - - -def main_command(): - """Process script configurations.""" - - __author__ = "Shawn Davis " - __date__ = VERSION_DATE - __help__ = """NOTES - -This command is used to parse configuration files and output the commands. - - """ - __version__ = VERSION - - # Main argument parser from which sub-commands are created. - parser = ArgumentParser(description=__doc__, epilog=__help__, formatter_class=RawDescriptionHelpFormatter) - - parser.add_argument( - "path", - default="commands.ini", - nargs="?", - help="The path to the configuration file." - ) - - parser.add_argument( - "-c", - "--color", - action="store_true", - dest="color_enabled", - help="Enable code highlighting for terminal output." - ) - - parser.add_argument( - "-C=", - "--context=", - action="append", - dest="variables", - help="Context variables for use in pre-parsing the config and templates. In the form of: name:value" - ) - - parser.add_argument( - "-d", - "--docs", - action="store_true", - dest="docs_enabled", - help="Output documentation instead of code." - ) - - # parser.add_argument( - # "-d=", - # "--docs=", - # choices=["html", "markdown", "plain", "rst"], - # dest="docs_enabled", - # help="Output documentation instead of code." - # ) - - parser.add_argument( - "-D", - "--debug", - action="store_true", - dest="debug_enabled", - help="Enable debug output." - ) - - parser.add_argument( - "-f=", - "--filter=", - action="append", - dest="filters", - help="Filter the commands in the form of: attribute:value" - ) - - parser.add_argument( - "-O=", - "--option=", - action="append", - dest="options", - help="Common command options in the form of: name:value" - ) - - # parser.add_argument( - # "-O=", - # "--output=", - # # default=os.path.join("prototype", "output"), - # dest="output_path", - # help="Output to the given directory. Defaults to ./prototype/output/" - # ) - - parser.add_argument( - "-s", - "--script", - action="store_true", - dest="script_enabled", - help="Output commands as a script." - ) - - parser.add_argument( - "-T=", - "--template-path=", - action="append", - dest="template_locations", - help="The location of template files that may be used with the template command." - ) - - parser.add_argument( - "-w=", - "--write=", - dest="output_file", - help="Write the output to disk." - ) - - parser.add_argument( - "-V=", - "--variables-file=", - dest="variables_file", - help="Load variables from a file." - ) - - # Access to the version number requires special consideration, especially - # when using sub parsers. The Python 3.3 behavior is different. See this - # answer: http://stackoverflow.com/questions/8521612/argparse-optional-subparser-for-version - parser.add_argument( - "-v", - action="version", - help="Show version number and exit.", - version=__version__ - ) - - parser.add_argument( - "--version", - action="version", - help="Show verbose version information and exit.", - version="%(prog)s" + " %s %s by %s" % (__version__, __date__, __author__) - ) - - # Parse arguments. - args = parser.parse_args() - - if args.debug_enabled: - log.setLevel(DEBUG) - - log.debug("Namespace: %s" % args) - - # Load context. - context = dict() - if args.variables: - context = initialize.context_from_cli(args.variables) - - # Load additional context from file. - if args.variables_file: - variables = initialize.variables_from_file(args.variables_file) - if variables: - context.update(variables) - - # Handle filters. - filters = None - if args.filters: - filters = initialize.filters_from_cli(args.filters) - - # Handle options. - options = None - if args.options: - options = initialize.options_from_cli(args.options) - - # Process the request. - if args.docs_enabled: - exit_code = subcommands.output_docs( - args.path, - context=context, - filters=filters, - locations=args.template_locations, - options=options + if loader is None: + exit(EXIT.ERROR) + + # Handle sub-commands. + if args.subcommand == "docs": + # noinspection PyUnboundLocalVariable + exit_code = subcommands.generate_docs( + loader, + output_file=args.output_file, + output_format=args.output_format ) - elif args.script_enabled: - exit_code = subcommands.output_script( - args.path, + elif args.subcommand in ["inv", "inventory"]: + exit_code = subcommands.copy_inventory(args.name, to_path=args.to_path) + elif args.subcommand == "script": + # noinspection PyUnboundLocalVariable + exit_code = subcommands.generate_script( + loader, color_enabled=args.color_enabled, - context=context, - locations=args.template_locations, - options=options + include_shebang=args.include_shebang, + output_file=args.output_file ) else: - exit_code = subcommands.output_commands( - args.path, - color_enabled=args.color_enabled, - context=context, - filters=filters, - locations=args.template_locations, - options=options - ) + print("Unrecognized command: %s" % args.subcommand) + exit_code = EXIT.USAGE exit(exit_code) diff --git a/scripttease/cli/initialize.py b/scripttease/cli/initialize.py index 70962ba..d7ff000 100644 --- a/scripttease/cli/initialize.py +++ b/scripttease/cli/initialize.py @@ -1,126 +1,374 @@ -# Imports +import logging from commonkit import smart_cast -from configparser import ConfigParser +from commonkit.shell import EXIT import logging import os -from ..constants import LOGGER_NAME +from ..lib.loaders import load_variables, INILoader, YMLLoader +from ..variables import LOGGER_NAME, PATH_TO_SCRIPT_TEASE log = logging.getLogger(LOGGER_NAME) -# Exports - -__all__ = ( - "context_from_cli", - "filters_from_cli", - "options_from_cli", - "variables_from_file", -) - -# Functions - - -def context_from_cli(variables): - """Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary. - - :param variables: A list of strings of ``name:value`` pairs. - :type variables: list[str] - - :rtype: dict - - The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type. - - """ - context = dict() - for i in variables: - key, value = i.split(":") - context[key] = smart_cast(value) - - return context - - -def filters_from_cli(filters): - """Takes a list of filters given in the form of ``name:value`` and converts them to a dictionary. - - :param filters: A list of strings of ``attribute:value`` pairs. - :type filters: list[str] - - :rtype: dict - - """ - _filters = dict() - for i in filters: - key, value = i.split(":") - if key not in filters: - _filters[key] = list() - - _filters[key].append(value) - - return _filters - def options_from_cli(options): - """Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary. - - :param options: A list of strings of ``name:value`` pairs. - :type options: list[str] - - :rtype: dict - - The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type. - - """ _options = dict() - for i in options: - key, value = i.split(":") - _options[key] = smart_cast(value) + for token in options: + try: + key, value = token.split(":") + _options[key] = smart_cast(value) + except ValueError: + _options[token] = True return _options -def variables_from_file(path): - """Loads variables from a given INI file. - - :param path: The path to the INI file. - :type path: str - - :rtype: dict | None - - The resulting dictionary flattens the sections and values. For example: +def loader(path, context=None, options=None, template_locations=None): + # The path may have been given as a file name (steps.ini), path, or an inventory name. + input_locations = [ + path, + os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", path, "steps.ini"), + os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory", path, "steps.yml"), + ] + path = None + for location in input_locations: + if os.path.exists(location): + path = location + break + + if path is None: + log.warning("Path does not exist: %s" % path) + return None - .. code-block:: ini + # Initialize the loader. + if path.endswith(".ini"): + _loader = INILoader( + path, + context=context, + locations=template_locations, + **options + ) + elif path.endswith(".yml"): + _loader = YMLLoader( + path, + context=context, + locations=template_locations, + **options + ) + else: + log.error("Unsupported file format: %s" % path) + return None - [copyright] - name = ACME, Inc. - year = 2020 + # Load the commands. + if not _loader.load(): + log.error("Failed to load the input file: %s" % path) + return None - [domain] - name = example.com - tld = example_com + return _loader - The dictionary would contain: - .. code-block:: python +def subcommands(subparsers): + """Initialize sub-commands. - { - 'copyright_name': "ACME, Inc.", - 'copyright_year': 2020, - 'domain_name': "example.com", - 'domain_tld': "example_com", - } + :param subparsers: The subparsers instance from argparse. """ - if not os.path.exists(path): - log.warning("Variables file does not exist: %s" % path) - return None - - ini = ConfigParser() - ini.read(path) - - variables = dict() - for section in ini.sections(): - for key, value in ini.items(section): - key = "%s_%s" % (section, key) - variables[key] = smart_cast(value) - - return variables + sub = SubCommands(subparsers) + sub.docs() + sub.inventory() + sub.script() + + +def variables_from_cli(context, variables): + for token in variables: + try: + key, value = token.split(":") + context.add(key, smart_cast(value)) + except ValueError: + context.add(token, True) + + +class SubCommands(object): + + def __init__(self, subparsers): + self.subparsers = subparsers + + def docs(self): + sub = self.subparsers.add_parser( + "docs", + help="Output documentation instead of code." + ) + + sub.add_argument( + "-o=", + "--output-format=", + choices=["html", "md", "plain", "rst"], + default="md", + dest="output_format", + help="The output format; HTML, Markdown, plain text, or ReStructuredText." + ) + + self._add_script_options(sub) + self._add_common_options(sub) + + def inventory(self): + sub = self.subparsers.add_parser( + "inventory", + aliases=["inv"], + help="Copy an inventory item to a local directory." + ) + + sub.add_argument( + "name", + help="The name of the inventory item. Use ? to list available items." + ) + + sub.add_argument( + "-P=", + "--path=", + dest="to_path", + help="The path to where the item should be copied. Defaults to the current working directory." + ) + + self._add_common_options(sub) + + def script(self): + sub = self.subparsers.add_parser( + "script", + help="Output the commands." + ) + + sub.add_argument( + "-c", + "--color", + action="store_true", + dest="color_enabled", + help="Enable code highlighting for terminal output." + ) + + sub.add_argument( + "-s", + "--shebang", + action="store_true", + dest="include_shebang", + help="Add the shebang to the beginning of the output." + ) + + self._add_script_options(sub) + self._add_common_options(sub) + + def _add_common_options(self, sub): + sub.add_argument( + "-D", + "--debug", + action="store_true", + dest="debug_enabled", + help="Enable debug mode. Produces extra output." + ) + + sub.add_argument( + "-p", + action="store_true", + dest="preview_enabled", + help="Preview mode." + ) + + def _add_script_options(self, sub): + sub.add_argument( + "-C=", + "--context=", + action="append", + dest="variables", + help="Context variables for use in pre-parsing the config and templates. In the form of: name:value" + ) + + sub.add_argument( + "-i=", + "--input-file=", + default="commands.ini", + dest="command_file", + help="The path to the configuration file." + ) + + # sub.add_argument( + # "-f=", + # "--filter=", + # action="append", + # dest="filters", + # help="Filter the commands in the form of: attribute:value" + # ) + + # Capture filters. + # if args.filters: + # filters = dict() + # for token in args.filters: + # key, value = token.split(":") + # if key not in filters: + # filters[key] = list() + # + # filters[key].append(value) + + sub.add_argument( + "-O=", + "--option=", + action="append", + dest="options", + help="Common command options in the form of: name:value" + ) + + sub.add_argument( + "-P=", + "--profile=", + choices=["centos", "ubuntu"], + default="ubuntu", + dest="profile", + help="The OS profile to use." + ) + + sub.add_argument( + "-T=", + "--template-path=", + action="append", + dest="template_locations", + help="The location of template files that may be used with the template command." + ) + + sub.add_argument( + "-w=", + "--write=", + dest="output_file", + help="Write the output to disk." + ) + + sub.add_argument( + "-V=", + "--variables-file=", + dest="variables_file", + help="Load variables from a file." + ) + +# # Imports +# +# from commonkit import smart_cast +# from configparser import ConfigParser +# import logging +# import os +# from ..constants import LOGGER_NAME +# +# log = logging.getLogger(LOGGER_NAME) +# +# # Exports +# +# __all__ = ( +# "context_from_cli", +# "filters_from_cli", +# "options_from_cli", +# "variables_from_file", +# ) +# +# # Functions +# +# +# def context_from_cli(variables): +# """Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary. +# +# :param variables: A list of strings of ``name:value`` pairs. +# :type variables: list[str] +# +# :rtype: dict +# +# The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type. +# +# """ +# context = dict() +# for i in variables: +# key, value = i.split(":") +# context[key] = smart_cast(value) +# +# return context +# +# +# def filters_from_cli(filters): +# """Takes a list of filters given in the form of ``name:value`` and converts them to a dictionary. +# +# :param filters: A list of strings of ``attribute:value`` pairs. +# :type filters: list[str] +# +# :rtype: dict +# +# """ +# _filters = dict() +# for i in filters: +# key, value = i.split(":") +# if key not in filters: +# _filters[key] = list() +# +# _filters[key].append(value) +# +# return _filters +# +# +# def options_from_cli(options): +# """Takes a list of variables given in the form of ``name:value`` and converts them to a dictionary. +# +# :param options: A list of strings of ``name:value`` pairs. +# :type options: list[str] +# +# :rtype: dict +# +# The ``value`` of the pair passes through "smart casting" to convert it to the appropriate Python data type. +# +# """ +# _options = dict() +# for i in options: +# key, value = i.split(":") +# _options[key] = smart_cast(value) +# +# return _options +# +# +# def variables_from_file(path): +# """Loads variables from a given INI file. +# +# :param path: The path to the INI file. +# :type path: str +# +# :rtype: dict | None +# +# The resulting dictionary flattens the sections and values. For example: +# +# .. code-block:: ini +# +# [copyright] +# name = ACME, Inc. +# year = 2020 +# +# [domain] +# name = example.com +# tld = example_com +# +# The dictionary would contain: +# +# .. code-block:: python +# +# { +# 'copyright_name': "ACME, Inc.", +# 'copyright_year': 2020, +# 'domain_name': "example.com", +# 'domain_tld': "example_com", +# } +# +# """ +# if not os.path.exists(path): +# log.warning("Variables file does not exist: %s" % path) +# return None +# +# ini = ConfigParser() +# ini.read(path) +# +# variables = dict() +# for section in ini.sections(): +# for key, value in ini.items(section): +# key = "%s_%s" % (section, key) +# variables[key] = smart_cast(value) +# +# return variables diff --git a/scripttease/cli/subcommands.py b/scripttease/cli/subcommands.py index b570041..24e9931 100644 --- a/scripttease/cli/subcommands.py +++ b/scripttease/cli/subcommands.py @@ -1,153 +1,181 @@ # Imports -from commonkit import highlight_code +from commonkit import copy_tree, highlight_code, indent, write_file from commonkit.shell import EXIT -from ..parsers import load_commands, load_config +from markdown import markdown +import os +from ..lib.factories import command_factory +from ..constants import PROFILE +from ..variables import PATH_TO_SCRIPT_TEASE # Exports __all__ = ( - "output_commands", - "output_docs", - "output_script", + "copy_inventory", + "generate_docs", + "generate_script", ) # Functions -def output_commands(path, color_enabled=False, context=None, filters=None, locations=None, options=None): - """Output commands found in a given configuration file. +def copy_inventory(name, to_path=None): + """Copy an inventory item to a path. - :param path: The path to the configuration file. - :type path: str + :param name: The name of the inventory item. ``?`` will list available items. + :type name: str - :param color_enabled: Indicates the output should be colorized. - :type color_enabled: bool - - :param context: The context to be applied to the file before parsing it as configuration. - :type context: dict - - :param filters: Output only those commands which match the given filters. - :type filters: dict - - :param locations: The locations (paths) of additional resources. - :type locations: list[str] - - :param options: Options to be applied to all commands. - :type options: dict + :param to_path: The path to where the item should be copied. Defaults to the current working directory. + :type to_path: str :rtype: int :returns: An exit code. """ - commands = load_commands( - path, - context=context, - filters=filters, - locations=locations, - options=options - ) - if commands is None: - return EXIT.ERROR + path = os.path.join(PATH_TO_SCRIPT_TEASE, "data", "inventory") + if name == "?": + for d in os.listdir(path): + print(d) - output = list() - for command in commands: - statement = command.get_statement(cd=True) - if statement is None: - continue + return EXIT.OK - output.append(statement) - output.append("") + from_path = os.path.join(path, name) - if color_enabled: - print(highlight_code("\n".join(output), language="bash")) - else: - print("\n".join(output)) + if to_path is None: + to_path = os.path.join(os.getcwd(), name) + os.makedirs(to_path) - return EXIT.OK + if copy_tree(from_path, to_path): + return EXIT.OK + return EXIT.ERROR -def output_docs(path, context=None, filters=None, locations=None, options=None): - """Output documentation for commands found in a given configuration file. - :param path: The path to the configuration file. - :type path: str +def generate_docs(loader, output_file=None, output_format="md", profile=PROFILE.UBUNTU): + """Generate documentation from a commands file. - :param context: The context to be applied to the file before parsing it as configuration. - :type context: dict + :param loader: The loader instance. + :type loader: BaseType[scripttease.lib.loaders.BaseLoader] - :param filters: Output only those commands which match the given filters. - :type filters: dict + :param output_file: The path to the output file. + :type output_file: str - :param locations: The locations (paths) of additional resources. - :type locations: list[str] + :param output_format: The output format; ``html``, ``md`` (Markdown, the default), ``plain`` (text), ``rst`` + (ReStructuredText). + :type output_format: str - :param options: Options to be applied to all commands. - :type options: dict + :param profile: The operating system profile to use. + :type profile: str :rtype: int :returns: An exit code. """ - commands = load_commands( - path, - context=context, - filters=filters, - locations=locations, - options=options - ) + commands = command_factory(loader, profile=profile) if commands is None: return EXIT.ERROR - count = 1 output = list() for command in commands: - output.append("%s. %s" % (count, command.comment)) - count += 1 - print("\n".join(output)) + if command.name in ("explain", "screenshot"): + output.append(command.get_output(output_format)) + elif command.name == "template": + if output_format == "plain": + output.append("+++") + output.append(command.get_content()) + output.append("+++") + output.append("") + elif output_format == "rst": + output.append(".. code-block:: %s" % command.get_target_language()) + output.append("") + output.append(indent(command.get_content())) + output.append("") + else: + output.append("```%s" % command.get_target_language()) + output.append(command.get_content()) + output.append("```") + output.append("") + else: + statement = command.get_statement(include_comment=False, include_register=False, include_stop=False) + if statement is not None: + line = command.comment.replace("#", "") + output.append("%s:" % line.capitalize()) + output.append("") + if output_format == "plain": + output.append("---") + output.append(statement) + output.append("---") + output.append("") + elif output_format == "rst": + output.append(".. code-block:: bash") + output.append("") + output.append(indent(statement)) + output.append("") + else: + output.append("```bash") + output.append(statement) + output.append("```") + output.append("") + + if output_format == "html": + _output = markdown("\n".join(output), extensions=['fenced_code']) + else: + _output = "\n".join(output) + + print(_output) + + if output_file: + write_file(output_file, _output) return EXIT.OK -def output_script(path, color_enabled=False, context=None, filters=None, locations=None, options=None): - """Output a script of commands found in a given configuration file. +def generate_script(loader, color_enabled=False, include_shebang=False, output_file=None, profile=PROFILE.UBUNTU): + """Generate statements from a commands file. - :param path: The path to the configuration file. - :type path: str + :param loader: The loader instance. + :type loader: BaseType[scripttease.lib.loaders.BaseLoader] - :param color_enabled: Indicates the output should be colorized. + :param color_enabled: Colorize the output. :type color_enabled: bool - :param context: The context to be applied to the file before parsing it as configuration. - :type context: dict - - :param filters: Output only those commands which match the given filters. NOT IMPLEMENTED. - :type filters: dict + :param include_shebang: Add the shebang to the beginning of the output. + :type include_shebang: bool - :param locations: The locations (paths) of additional resources. - :type locations: list[str] + :param output_file: The path to the output file. + :type output_file: str - :param options: Options to be applied to all commands. - :type options: dict + :param profile: The operating system profile to use. + :type profile: str :rtype: int :returns: An exit code. """ - config = load_config( - path, - context=context, - locations=locations, - options=options - ) - if config is None: + commands = command_factory(loader, profile=profile) + if commands is None: return EXIT.ERROR - script = config.as_script() + output = list() + if include_shebang: + output.append("#! /usr/bin/env bash") + + for command in commands: + if command.name in ("explain", "screenshot"): + continue + + statement = command.get_statement(include_comment=True) + if statement is not None: + output.append(statement) + output.append("") + if color_enabled: - print(highlight_code(script.to_string(), language="bash")) + print(highlight_code("\n".join(output), language="bash")) else: - print(script) + print("\n".join(output)) + + if output_file: + write_file(output_file, "\n".join(output)) return EXIT.OK diff --git a/scripttease/data/inventory/nextcloud/meta.ini b/scripttease/data/inventory/nextcloud/meta.ini new file mode 100644 index 0000000..af91881 --- /dev/null +++ b/scripttease/data/inventory/nextcloud/meta.ini @@ -0,0 +1,6 @@ +[package] +description = Install Nextcloud. +docs = https://nextcloud.com +tags = collaboration +title = Nextcloud +version = 0.1.0-d diff --git a/scripttease/data/inventory/pgsql/meta.ini b/scripttease/data/inventory/pgsql/meta.ini new file mode 100644 index 0000000..514dc6e --- /dev/null +++ b/scripttease/data/inventory/pgsql/meta.ini @@ -0,0 +1,6 @@ +[package] +description = Install PostgreSQL. +docs = https://postgresql.org +tags = database, postgres +title = PostgreSQL +version = 0.1.0-d diff --git a/scripttease/data/inventory/radicale/steps.ini b/scripttease/data/inventory/radicale/steps.ini index 52cef64..45173dc 100644 --- a/scripttease/data/inventory/radicale/steps.ini +++ b/scripttease/data/inventory/radicale/steps.ini @@ -3,7 +3,7 @@ explain: In this tutorial, we are going to install Radicale. header: yes [make sure a maintenance root exists] -mkdir: /var/www/maint/www +dir: /var/www/maint/www group: www-data owner: www-data recursive: yes @@ -12,14 +12,14 @@ recursive: yes explain: The maintenance root is used to register an SSL certificate (below) before the site is completed and (later) after the site is live. [install radicale] -pip3: radicale +pip: radicale [install radicale screenshot] screenshot: images/install.png caption: Radical Installed [create radicale configuration directory] -mkdir: /etc/radicale/config +dir: /etc/radicale/config owner: radicale recursive: yes @@ -27,7 +27,7 @@ recursive: yes template: config.ini /etc/radicale/config/config.ini [create the radicale user] -user.add: radicale +user: radicale home: / login: /sbin/nologin system: yes diff --git a/scripttease/data/inventory/ubuntu/meta.ini b/scripttease/data/inventory/ubuntu/meta.ini new file mode 100644 index 0000000..c71b36e --- /dev/null +++ b/scripttease/data/inventory/ubuntu/meta.ini @@ -0,0 +1,6 @@ +[package] +description = Set up an Ubuntu server. +docs = https://ubuntu.com +tags = operating system +title = Ubuntu +version = 0.1.0-d diff --git a/scripttease/lib/commands/base.py b/scripttease/lib/commands/base.py index d73379f..190cae1 100644 --- a/scripttease/lib/commands/base.py +++ b/scripttease/lib/commands/base.py @@ -228,12 +228,16 @@ class Content(object): if self.width is not None: a.append(indent(":width: %s" % self.width, 8)) + + a.append("") else: if self.caption: a.append("%s: %s" % (self.caption, self.image)) else: a.append(self.image) + a.append("") + return "\n".join(a) def _get_message_output(self, output_format): @@ -566,12 +570,19 @@ class Template(object): def __init__(self, source, target, backup=True, parser=PARSER_JINJA, **kwargs): self.backup_enabled = backup - self.context = kwargs.pop("context", dict()) + self.cd = kwargs.pop("cd", None) + self.comment = kwargs.pop("comment", "create template %s" % target) + self.condition = kwargs.pop("condition", None) + # self.context = kwargs.pop("context", dict()) self.name = "template" self.parser = parser + self.prefix = kwargs.pop("prefix", None) self.language = kwargs.pop("lang", None) self.locations = kwargs.pop("locations", list()) + self.register = kwargs.pop("register", None) self.source = os.path.expanduser(source) + self.stop = kwargs.pop("stop", False) + self.tags = kwargs.pop("tags", list()) self.target = target sudo = kwargs.pop("sudo", None) @@ -584,10 +595,10 @@ class Template(object): else: self.sudo = Sudo() - self.kwargs = kwargs + self.context = kwargs def __getattr__(self, item): - return self.kwargs.get(item) + return self.context.get(item) # def __str__(self): # return "template" diff --git a/scripttease/lib/commands/django.py b/scripttease/lib/commands/django.py index c909def..8d02aa1 100644 --- a/scripttease/lib/commands/django.py +++ b/scripttease/lib/commands/django.py @@ -23,6 +23,27 @@ from .base import Command def django(management_command, *args, excluded_kwargs=None, **kwargs): + """Common function for assembling Django management commands. + + :param management_command: The name of the management command. + :type management_command: str + + :param excluded_kwargs: A dictionary of kwargs that should be excluded from the management command parameters. + :param excluded_kwargs: dict + + :rtype: scripttease.lib.commands.base.Command + + If provided, args are passed directly to the command as positional parameters. + + Any provided kwargs are converted to long form parameters. For example, database="alternative_db" becomes + ``--database="alternative_db"``. + + A kwarg with a ``True`` value becomes a long form parameter with no value. For example, natural_foreign=True becomes + ``--natural-foreign``. + + Finally, any kwarg that is not a string is passed without quotes. For example, testing=1 becomes ``--testing=1``. + + """ # The excluded parameters (filtered below) may vary based on implementation. We do, however, need a default. excluded_kwargs = excluded_kwargs or EXCLUDED_KWARGS @@ -61,37 +82,92 @@ def django(management_command, *args, excluded_kwargs=None, **kwargs): def django_check(**kwargs): + """Run Django checks.""" kwargs.setdefault("comment", "run django checks") kwargs.setdefault("register", "django_checks_out") return django("check", **kwargs) +def django_createsuperuser(username, email=None, **kwargs): + """Create a superuser account. + + :param username: The name for the user account. + :type username: str + + :param email: The user's email address. Optional, but recommended because the account must be created without a + password. + :type email: str + + """ + kwargs.setdefault("comment", "create the %s superuser" % username) + kwargs['username'] = username + kwargs['noinput'] = True + + if email is not None: + kwargs['email'] = email + + return django("createsuperuser", **kwargs) + + def django_dump(target, path=None, **kwargs): + """Dump data fixtures. + + :param target: The app name or ``app.ModelName``. + :type target: str + + :param path: The path to the fixture file. + :type path: str + + """ kwargs.setdefault("comment", "dump app/model data for %s" % target) kwargs.setdefault("format", "json") kwargs.setdefault("indent", 4) + app = target + file_name = "%s/initial.%s" % (app, kwargs['format']) + if "." in target: + app, model = target.split(".") + file_name = "%s/%s.%s" % (app, model.lower(), kwargs['format']) + if path is None: - path = "../fixtures/%s.%s" % (target, kwargs['format']) + path = "../fixtures/%s" % file_name return django("dumpdata", target, "> %s" % path, **kwargs) def django_load(target, path=None, **kwargs): + """Load data fixtures. + + :param target: The app name or ``app.ModelName``. + :type target: str + + :param path: The path to the fixture file. + :type path: str + + """ kwargs.setdefault("comment", "load app/model data from %s" % target) input_format = kwargs.pop("format", "json") + + app = target + file_name = "%s/initial.%s" % (app, input_format) + if "." in target: + app, model = target.split(".") + file_name = "%s/%s.%s" % (app, model.lower(), input_format) + if path is None: - path = "../fixtures/%s.%s" % (target, input_format) + path = "../fixtures/%s" % file_name return django("loaddata", path, **kwargs) def django_migrate(**kwargs): + """Apply database migrations.""" kwargs.setdefault("comment", "apply database migrations") return django("migrate", **kwargs) def django_static(**kwargs): + """Collect static files.""" kwargs.setdefault("comment", "collect static files") kwargs.setdefault("noinput", True) return django("collectstatic", **kwargs) @@ -100,6 +176,8 @@ def django_static(**kwargs): DJANGO_MAPPINGS = { 'django': django, 'django.check': django_check, + 'django.collectstatic': django_static, + 'django.createsuperuser': django_createsuperuser, 'django.dump': django_dump, 'django.dumpdata': django_dump, 'django.load': django_load, diff --git a/scripttease/lib/commands/posix.py b/scripttease/lib/commands/posix.py index 3182bd1..9099cf6 100644 --- a/scripttease/lib/commands/posix.py +++ b/scripttease/lib/commands/posix.py @@ -610,6 +610,7 @@ POSIX_MAPPINGS = { 'run': run, 'rsync': rsync, 'scopy': scopy, + 'ssl': certbot, 'sync': sync, 'touch': touch, 'wait': wait, diff --git a/scripttease/lib/commands/ubuntu.py b/scripttease/lib/commands/ubuntu.py index c35e479..3aef7c6 100644 --- a/scripttease/lib/commands/ubuntu.py +++ b/scripttease/lib/commands/ubuntu.py @@ -1,7 +1,7 @@ # Imports from commonkit import split_csv -from .base import Command, Template +from .base import Command from .django import DJANGO_MAPPINGS from .messages import MESSAGE_MAPPINGS from .mysql import MYSQL_MAPPINGS diff --git a/scripttease/lib/factories.py b/scripttease/lib/factories.py index 50894fb..947ee4b 100644 --- a/scripttease/lib/factories.py +++ b/scripttease/lib/factories.py @@ -66,7 +66,7 @@ def command_factory(loader, excluded_kwargs=None, mappings=None, profile=PROFILE command.number = number if command.name == "template": - command.context = loader.get_context() + command.context.update(loader.get_context()) commands.append(command) diff --git a/scripttease/lib/loaders/ini.py b/scripttease/lib/loaders/ini.py index b86d26d..bb5ce2e 100644 --- a/scripttease/lib/loaders/ini.py +++ b/scripttease/lib/loaders/ini.py @@ -61,6 +61,7 @@ class INILoader(BaseLoader): # by double quotes. if command_name in ("explain", "screenshot"): args.append(value) + count += 1 continue # Arguments surrounded by quotes are considered to be one argument. All others are split into a diff --git a/setup.py b/setup.py index 89d4032..c48a6f4 100644 --- a/setup.py +++ b/setup.py @@ -20,11 +20,11 @@ setup( author='Shawn Davis', author_email='shawn@develmaycare.com', url='https://develmaycare.com/products/python/scripttease/', - download_url='https://github.com/develmaycare/python-scripttease', + download_url='https://gittraction.com/diff6/python-scripttease', project_urls={ 'Documentation': "https://docs.develmaycare.com/en/python-scripttease/latest/", - 'Source': "https://github.com/develmaycare/python-scripttease", - 'Tracker': "https://github.com/develmaycare/python-scripttease/issues/" + 'Source': "https://gittraction.com/diff6/python-scripttease", + 'Tracker': "https://gittraction.com/diff6/python-scripttease/issues" }, packages=find_packages(exclude=["tests", "tests.*"]), include_package_data=True, diff --git a/tests/examples/users.ini b/tests/examples/users.ini new file mode 100644 index 0000000..ff4bb75 --- /dev/null +++ b/tests/examples/users.ini @@ -0,0 +1,9 @@ +[create the deploy user] +user: deploy +groups: sudo, www-data +home: /var/www +sudo: yes + +[remove a user] +user: bob +op: remove diff --git a/tests/test_lib_commands.py b/tests/test_lib_commands.py new file mode 100644 index 0000000..38335fb --- /dev/null +++ b/tests/test_lib_commands.py @@ -0,0 +1,9 @@ +from scripttease.lib.factories import command_factory +from scripttease.lib.loaders import INILoader + + +def test_user_commands(): + ini = INILoader("tests/examples/users.ini") + ini.load() + commands = command_factory(ini) + print(commands) \ No newline at end of file diff --git a/tests/test_lib_commands_base.py b/tests/test_lib_commands_base.py index b38066a..640b24d 100644 --- a/tests/test_lib_commands_base.py +++ b/tests/test_lib_commands_base.py @@ -255,6 +255,22 @@ class TestSudo(object): class TestTemplate(object): + def test_getattr(self): + context = { + 'testing': "yes", + 'times': 123, + } + t = Template( + "tests/examples/templates/simple.txt", + "tests/tmp/simple.txt", + backup=False, + # context=context, + parser=Template.PARSER_SIMPLE, + **context + ) + assert t.testing == "yes" + assert t.times == 123 + def test_get_content(self): context = { 'testing': "yes", @@ -264,8 +280,9 @@ class TestTemplate(object): "tests/examples/templates/simple.txt", "tests/tmp/simple.txt", backup=False, - context=context, - parser=Template.PARSER_SIMPLE + # context=context, + parser=Template.PARSER_SIMPLE, + **context ) content = t.get_content() assert "I am testing? yes" in content @@ -279,8 +296,9 @@ class TestTemplate(object): "tests/examples/templates/simple.sh.txt", "tests/tmp/simple.sh", backup=False, - context=context, - parser=Template.PARSER_SIMPLE + # context=context, + parser=Template.PARSER_SIMPLE, + **context ) content = t.get_content() assert "I am testing? yes" in content @@ -294,7 +312,8 @@ class TestTemplate(object): "tests/examples/templates/good.j2.txt", "tests/tmp/good.txt", backup=False, - context=context + # context=context + **context ) content = t.get_content() assert "I am testing? yes" in content @@ -310,7 +329,13 @@ class TestTemplate(object): 'testing': True, 'times': 3, } - t = Template("tests/examples/templates/settings.py", "test/tmp/settings.py", context=context, parser=Template.PARSER_PYTHON) + t = Template( + "tests/examples/templates/settings.py", + "test/tmp/settings.py", + # context=context, + parser=Template.PARSER_PYTHON, + **context + ) content = t.get_content() assert "TESTING = True" in content assert "TOTAL_TIMES = 3" in content @@ -323,12 +348,13 @@ class TestTemplate(object): t = Template( "tests/examples/templates/simple.txt", "tests/tmp/simple.txt", - context=context, + # context=context, comment="A simple parser example.", parser=Template.PARSER_SIMPLE, register="template_created", stop=True, - sudo=Sudo(user="root") + sudo=Sudo(user="root"), + **context ) s = t.get_statement() assert "I am testing? yes" in s @@ -341,10 +367,11 @@ class TestTemplate(object): t = Template( "tests/examples/templates/simple.sh.txt", "tests/tmp/simple.txt", - context=context, + # context=context, parser=Template.PARSER_SIMPLE, stop=True, - sudo="root" + sudo="root", + **context ) s = t.get_statement() assert "I am testing? yes" in s @@ -357,8 +384,9 @@ class TestTemplate(object): t = Template( "tests/examples/templates/good.j2.txt", "tests/tmp/good.txt", - context=context, - sudo=True + # context=context, + sudo=True, + **context ) s = t.get_statement() assert "I am testing? yes" in s diff --git a/tests/test_lib_commands_django.py b/tests/test_lib_commands_django.py index ac8cb59..a43cc56 100644 --- a/tests/test_lib_commands_django.py +++ b/tests/test_lib_commands_django.py @@ -35,6 +35,15 @@ def test_django_collect_static(): assert "source python/bin/activate" in s +def test_django_createsuperuser(): + c = django_createsuperuser("root", email="root@example.com") + s = c.get_statement() + assert "./manage.py createsuperuser" in s + assert '--username="root"' in s + assert '--email="root@example.com"' in s + assert '--noinput' in s + + def test_django_dumpdata(): c = django_dump("projects") s = c.get_statement() @@ -42,15 +51,27 @@ def test_django_dumpdata(): assert "projects >" in s assert '--format="json"' in s assert "--indent=4" in s - assert "../fixtures/projects.json" in s + assert "../fixtures/projects/initial.json" in s + + c = django_dump("projects.Category") + s = c.get_statement() + assert "./manage.py dumpdata" in s + assert "projects.Category >" in s + assert '--format="json"' in s + assert "--indent=4" in s + assert "../fixtures/projects/category.json" in s def test_django_loaddata(): c = django_load("projects") s = c.get_statement() - print(s) assert "./manage.py loaddata" in s - assert "../fixtures/projects.json" in s + assert "../fixtures/projects/initial.json" in s + + c = django_load("projects.Category") + s = c.get_statement() + assert "./manage.py loaddata" in s + assert "../fixtures/projects/category.json" in s def test_django_migrate():